From f38b6ce9ace0d370e374bd62ead12e66000d0e87 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 13:44:23 +0000 Subject: [PATCH 01/12] refactor: extract ViewportState, add common styles, remove wrapper (#87 #88 #89) - Add ViewportState type to eliminate duplicated viewport init pattern (6 views) - Add TitleStyle, SelectedStyle, TableHeaderStyle to theme.go - Remove unnecessary getCloudWatchLogsClient wrapper in log-streams --- custom/cloudwatch/log-streams/actions.go | 6 +-- internal/ui/theme.go | 14 ++++++- internal/view/detail_view.go | 38 +++++------------- internal/view/detail_view_test.go | 3 +- internal/view/diff_view.go | 22 +++------- internal/view/diff_view_test.go | 10 ++--- internal/view/help_view.go | 29 +++++--------- internal/view/log_view.go | 43 ++++++++------------ internal/view/log_view_test.go | 4 +- internal/view/multi_selector.go | 38 +++++++----------- internal/view/service_browser.go | 51 +++++++----------------- internal/view/viewport_helper.go | 18 +++++++++ 12 files changed, 110 insertions(+), 166 deletions(-) create mode 100644 internal/view/viewport_helper.go diff --git a/custom/cloudwatch/log-streams/actions.go b/custom/cloudwatch/log-streams/actions.go index 39226dfe..a4502230 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -34,17 +34,13 @@ func executeLogStreamAction(ctx context.Context, act action.Action, resource dao } } -func getCloudWatchLogsClient(ctx context.Context) (*cloudwatchlogs.Client, error) { - return cwClient.GetLogsClient(ctx) -} - func executeDeleteLogStream(ctx context.Context, resource dao.Resource) action.ActionResult { ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return action.InvalidResourceResult() } - client, err := getCloudWatchLogsClient(ctx) + client, err := cwClient.GetLogsClient(ctx) if err != nil { return action.ActionResult{Success: false, Error: err} } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 9af10b5c..451ec6c0 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -102,12 +102,22 @@ func WarningStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Warning) } -// DangerStyle returns a style for danger/error states func DangerStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Danger) } -// NewSpinner creates a consistently styled spinner for loading states +func TitleStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Primary) +} + +func SelectedStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(current.Selection).Foreground(current.SelectionText) +} + +func TableHeaderStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(current.TableHeader).Foreground(current.TableHeaderText) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index f4c38727..784cd462 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -5,7 +5,6 @@ import ( "strings" "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -40,15 +39,14 @@ type DetailView struct { renderer render.Renderer service string resType string - viewport viewport.Model + vp ViewportState headerPanel *HeaderPanel - ready bool width int height int registry *registry.Registry - dao dao.DAO // for async refresh - refreshing bool // true while fetching extended details - refreshErr error // error from last refresh attempt + dao dao.DAO + refreshing bool + refreshErr error spinner spinner.Model styles detailViewStyles } @@ -110,12 +108,10 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { d.refreshErr = msg.err } else { d.refreshErr = nil - // Merge refreshed resource with original to preserve List-only fields d.resource = mergeResources(d.resource, msg.resource) - // Re-render content with refreshed data - if d.ready { + if d.vp.Ready { content := d.renderContent() - d.viewport.SetContent(content) + d.vp.Model.SetContent(content) } } return d, nil @@ -149,9 +145,8 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Pass other messages to viewport for scrolling var cmd tea.Cmd - d.viewport, cmd = d.viewport.Update(msg) + d.vp.Model, cmd = d.vp.Model.Update(msg) return d, cmd } @@ -174,22 +169,19 @@ func (d *DetailView) handleNavigation(key string) (tea.Model, tea.Cmd) { return nil, nil } -// ViewString returns the view content as a string func (d *DetailView) ViewString() string { - if !d.ready { + if !d.vp.Ready { return "Loading..." } - // Get summary fields for header var summaryFields []render.SummaryField if d.renderer != nil { summaryFields = d.renderer.RenderSummary(dao.UnwrapResource(d.resource)) } - // Render header panel header := d.headerPanel.Render(d.service, d.resType, summaryFields) - return header + "\n" + d.viewport.View() + return header + "\n" + d.vp.Model.View() } // View implements tea.Model @@ -213,23 +205,15 @@ func (d *DetailView) SetSize(width, height int) tea.Cmd { headerStr := d.headerPanel.Render(d.service, d.resType, summaryFields) headerHeight := d.headerPanel.Height(headerStr) - // height - header + extra space viewportHeight := height - headerHeight + 1 if viewportHeight < 5 { viewportHeight = 5 } - if !d.ready { - d.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - d.ready = true - } else { - d.viewport.SetWidth(width) - d.viewport.SetHeight(viewportHeight) - } + d.vp.SetSize(width, viewportHeight) - // Render content content := d.renderContent() - d.viewport.SetContent(content) + d.vp.Model.SetContent(content) return nil } diff --git a/internal/view/detail_view_test.go b/internal/view/detail_view_test.go index b5a7ed94..c4cf4b40 100644 --- a/internal/view/detail_view_test.go +++ b/internal/view/detail_view_test.go @@ -230,8 +230,7 @@ func TestDetailViewLoadingPlaceholderReplacement(t *testing.T) { dv.refreshing = tt.refreshing dv.SetSize(100, 50) - // Get the viewport content - content := dv.viewport.View() + content := dv.vp.Model.View() for _, want := range tt.wantContains { if !strings.Contains(content, want) { diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 7cc45e31..cae0556f 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -4,7 +4,6 @@ import ( "context" "strings" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -14,7 +13,6 @@ import ( "github.com/clawscli/claws/internal/ui" ) -// DiffView displays side-by-side comparison of two resources type DiffView struct { ctx context.Context left dao.Resource @@ -22,8 +20,7 @@ type DiffView struct { renderer render.Renderer service string resourceType string - viewport viewport.Model - ready bool + vp ViewportState width int height int styles diffViewStyles @@ -75,17 +72,16 @@ func (d *DiffView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd - d.viewport, cmd = d.viewport.Update(msg) + d.vp.Model, cmd = d.vp.Model.Update(msg) return d, cmd } -// ViewString returns the view content as a string func (d *DiffView) ViewString() string { - if !d.ready { + if !d.vp.Ready { return "Loading..." } - return d.viewport.View() + return d.vp.Model.View() } // View implements tea.Model @@ -105,16 +101,10 @@ func (d *DiffView) SetSize(width, height int) tea.Cmd { viewportHeight = 5 } - if !d.ready { - d.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - d.ready = true - } else { - d.viewport.SetWidth(width) - d.viewport.SetHeight(viewportHeight) - } + d.vp.SetSize(width, viewportHeight) content := d.renderSideBySide() - d.viewport.SetContent(content) + d.vp.Model.SetContent(content) return nil } diff --git a/internal/view/diff_view_test.go b/internal/view/diff_view_test.go index 9573d92f..19261a00 100644 --- a/internal/view/diff_view_test.go +++ b/internal/view/diff_view_test.go @@ -49,16 +49,14 @@ func TestDiffView_SetSize(t *testing.T) { dv := NewDiffView(ctx, left, right, nil, "ec2", "instances") - // Initially not ready - if dv.ready { - t.Error("Expected ready to be false initially") + if dv.vp.Ready { + t.Error("Expected vp.Ready to be false initially") } - // SetSize should initialize viewport dv.SetSize(100, 50) - if !dv.ready { - t.Error("Expected ready to be true after SetSize") + if !dv.vp.Ready { + t.Error("Expected vp.Ready to be true after SetSize") } if dv.width != 100 { t.Errorf("width = %d, want 100", dv.width) diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 836dd6f1..feea2f8c 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -1,7 +1,6 @@ package view import ( - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -28,11 +27,10 @@ func newHelpViewStyles() helpViewStyles { } type HelpView struct { - width int - height int - styles helpViewStyles - viewport viewport.Model - ready bool + width int + height int + styles helpViewStyles + vp ViewportState } // NewHelpView creates a new HelpView @@ -47,10 +45,9 @@ func (h *HelpView) Init() tea.Cmd { return nil } -// Update implements tea.Model func (h *HelpView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - h.viewport, cmd = h.viewport.Update(msg) + h.vp.Model, cmd = h.vp.Model.Update(msg) return h, cmd } @@ -156,12 +153,11 @@ func (h *HelpView) renderContent() string { return out } -// ViewString returns the view content as a string func (h *HelpView) ViewString() string { - if !h.ready { + if !h.vp.Ready { return "Loading..." } - return h.viewport.View() + return h.vp.Model.View() } // View implements tea.Model @@ -169,19 +165,12 @@ func (h *HelpView) View() tea.View { return tea.NewView(h.ViewString()) } -// SetSize implements View func (h *HelpView) SetSize(width, height int) tea.Cmd { h.width = width h.height = height - if !h.ready { - h.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) - h.ready = true - } else { - h.viewport.SetWidth(width) - h.viewport.SetHeight(height) - } - h.viewport.SetContent(h.renderContent()) + h.vp.SetSize(width, height) + h.vp.Model.SetContent(h.renderContent()) return nil } diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 7d317245..b04d24f6 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -7,7 +7,6 @@ import ( "time" "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -36,15 +35,14 @@ type LogView struct { logGroupName string logStreamName string - viewport viewport.Model - spinner spinner.Model - styles logViewStyles + vp ViewportState + spinner spinner.Model + styles logViewStyles logs []logEntry loading bool paused bool err error - ready bool width int height int @@ -266,7 +264,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.lastEventTime > 0 { v.oldestEventTime = msg.lastEventTime } - if v.ready { + if v.vp.Ready { v.updateViewportContent() } } @@ -283,9 +281,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(v.logs) > maxLogBufferSize { v.logs = v.logs[len(v.logs)-maxLogBufferSize:] } - if v.ready { + if v.vp.Ready { v.updateViewportContent() - v.viewport.GotoBottom() + v.vp.Model.GotoBottom() } } if !v.paused { @@ -308,19 +306,19 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return v, nil case "g": - if v.ready { - v.viewport.GotoTop() + if v.vp.Ready { + v.vp.Model.GotoTop() } return v, nil case "G": - if v.ready { - v.viewport.GotoBottom() + if v.vp.Ready { + v.vp.Model.GotoBottom() } return v, nil case "c": v.logs = v.logs[:0] v.oldestEventTime = 0 - if v.ready { + if v.vp.Ready { v.updateViewportContent() } return v, nil @@ -340,9 +338,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if v.ready { + if v.vp.Ready { var cmd tea.Cmd - v.viewport, cmd = v.viewport.Update(msg) + v.vp.Model, cmd = v.vp.Model.Update(msg) return v, cmd } return v, nil @@ -355,11 +353,11 @@ func (v *LogView) updateViewportContent() { msg := v.styles.message.Render(entry.message) sb.WriteString(fmt.Sprintf("%s %s\n", ts, msg)) } - v.viewport.SetContent(sb.String()) + v.vp.Model.SetContent(sb.String()) } func (v *LogView) ViewString() string { - if !v.ready { + if !v.vp.Ready { return "Loading..." } @@ -395,7 +393,7 @@ func (v *LogView) ViewString() string { return sb.String() } - sb.WriteString(v.viewport.View()) + sb.WriteString(v.vp.Model.View()) return sb.String() } @@ -408,14 +406,7 @@ func (v *LogView) SetSize(width, height int) tea.Cmd { v.height = height viewportHeight := height - viewportHeaderOffset - if !v.ready { - v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - v.ready = true - } else { - v.viewport.SetWidth(width) - v.viewport.SetHeight(viewportHeight) - } - + v.vp.SetSize(width, viewportHeight) v.updateViewportContent() return nil } diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index 635023f5..02a048d4 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -259,8 +259,8 @@ func TestLogViewSetSize(t *testing.T) { if cmd != nil { t.Error("Expected SetSize to return nil cmd") } - if !lv.ready { - t.Error("Expected ready to be true after SetSize") + if !lv.vp.Ready { + t.Error("Expected vp.Ready to be true after SetSize") } } diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index b1236ae1..37446597 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -4,7 +4,6 @@ import ( "strings" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -41,8 +40,7 @@ type MultiSelector[T SelectorItem] struct { cursor int selected map[string]bool - viewport viewport.Model - ready bool + vp ViewportState filterInput textinput.Model filterActive bool @@ -136,7 +134,7 @@ func (m *MultiSelector[T]) HandleUpdate(msg tea.Msg) (tea.Cmd, SelectorKeyResult switch msg := msg.(type) { case tea.MouseWheelMsg: var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + m.vp.Model, cmd = m.vp.Model.Update(msg) return cmd, KeyHandled case tea.MouseMotionMsg: @@ -228,7 +226,7 @@ func (m *MultiSelector[T]) HandleUpdate(msg tea.Msg) (tea.Cmd, SelectorKeyResult } var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + m.vp.Model, cmd = m.vp.Model.Update(msg) if cmd != nil { return cmd, KeyHandled } @@ -277,18 +275,18 @@ func (m *MultiSelector[T]) clampCursor() { } func (m *MultiSelector[T]) updateViewport() { - if !m.ready { + if !m.vp.Ready { return } - m.viewport.SetContent(m.renderContent()) + m.vp.Model.SetContent(m.renderContent()) if m.cursor >= 0 { - viewportHeight := m.viewport.Height() + viewportHeight := m.vp.Model.Height() if viewportHeight > 0 { - if m.cursor < m.viewport.YOffset() { - m.viewport.SetYOffset(m.cursor) - } else if m.cursor >= m.viewport.YOffset()+viewportHeight { - m.viewport.SetYOffset(m.cursor - viewportHeight + 1) + if m.cursor < m.vp.Model.YOffset() { + m.vp.Model.SetYOffset(m.cursor) + } else if m.cursor >= m.vp.Model.YOffset()+viewportHeight { + m.vp.Model.SetYOffset(m.cursor - viewportHeight + 1) } } } @@ -327,7 +325,7 @@ func (m *MultiSelector[T]) renderContent() string { } func (m *MultiSelector[T]) getItemAtPosition(y int) int { - if !m.ready { + if !m.vp.Ready { return -1 } headerHeight := 1 @@ -335,7 +333,7 @@ func (m *MultiSelector[T]) getItemAtPosition(y int) int { headerHeight++ } - contentY := y - headerHeight + m.viewport.YOffset() + contentY := y - headerHeight + m.vp.Model.YOffset() if contentY >= 0 && contentY < len(m.filtered) { return contentY } @@ -354,11 +352,11 @@ func (m *MultiSelector[T]) ViewString() string { filterView = m.styles.filter.Render("filter: "+m.filterText) + "\n" } - if !m.ready { + if !m.vp.Ready { return title + "\n" + filterView + "Loading..." } - return title + "\n" + filterView + m.viewport.View() + return title + "\n" + filterView + m.vp.Model.View() } func (m *MultiSelector[T]) SetSize(width, height int) { @@ -367,13 +365,7 @@ func (m *MultiSelector[T]) SetSize(width, height int) { viewportHeight-- } - if !m.ready { - m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - m.ready = true - } else { - m.viewport.SetWidth(width) - m.viewport.SetHeight(viewportHeight) - } + m.vp.SetSize(width, viewportHeight) m.updateViewport() } diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 6047df62..161ab2b1 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -5,7 +5,6 @@ import ( "strings" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -50,8 +49,7 @@ type ServiceBrowser struct { headerPanel *HeaderPanel // Viewport for scrolling - viewport viewport.Model - ready bool + vp ViewportState // Filter filterInput textinput.Model @@ -205,9 +203,8 @@ func (s *ServiceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s.handleNavigation(msg) case tea.MouseWheelMsg: - // Pass wheel events to viewport for scrolling var cmd tea.Cmd - s.viewport, cmd = s.viewport.Update(msg) + s.vp.Model, cmd = s.vp.Model.Update(msg) return s, cmd case tea.MouseMotionMsg: @@ -362,33 +359,28 @@ func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.C return s, nil } -// updateViewport updates the viewport content and scrolls to make cursor visible func (s *ServiceBrowser) updateViewport() { - if !s.ready { + if !s.vp.Ready { return } content := s.renderContent() - s.viewport.SetContent(content) + s.vp.Model.SetContent(content) - // Count total lines and find cursor line by scanning rendered content lines := strings.Split(content, "\n") totalLines := len(lines) - // Estimate cursor position based on proportion through flatItems if len(s.flatItems) == 0 { return } - // Calculate approximate line position cursorRatio := float64(s.cursor) / float64(len(s.flatItems)) targetLine := int(cursorRatio * float64(totalLines)) - vpHeight := s.viewport.Height() - currentTop := s.viewport.YOffset() + vpHeight := s.vp.Model.Height() + currentTop := s.vp.Model.YOffset() - // Scroll if cursor is outside visible area if targetLine < currentTop { - s.viewport.SetYOffset(max(0, targetLine-2)) + s.vp.Model.SetYOffset(max(0, targetLine-2)) } else if targetLine > currentTop+vpHeight-cellHeight { newOffset := targetLine - vpHeight + cellHeight + 2 if newOffset > totalLines-vpHeight { @@ -397,7 +389,7 @@ func (s *ServiceBrowser) updateViewport() { if newOffset < 0 { newOffset = 0 } - s.viewport.SetYOffset(newOffset) + s.vp.Model.SetYOffset(newOffset) } } @@ -458,18 +450,15 @@ func (s *ServiceBrowser) selectCurrentService() (tea.Model, tea.Cmd) { return s, nil } -// getItemAtPosition returns the item index at the given (x, y) position, or -1 if none func (s *ServiceBrowser) getItemAtPosition(x, y int) int { - if !s.ready || len(s.itemPositions) == 0 { + if !s.vp.Ready || len(s.itemPositions) == 0 { return -1 } - // Calculate header panel height dynamically headerStr := s.headerPanel.RenderHome() headerHeight := s.headerPanel.Height(headerStr) - // Adjust y for header and viewport scroll offset - contentY := y - headerHeight + s.viewport.YOffset() + contentY := y - headerHeight + s.vp.Model.YOffset() if contentY < 0 { return -1 } @@ -489,22 +478,19 @@ func (s *ServiceBrowser) getItemAtPosition(x, y int) int { return -1 } -// ViewString returns the view content as a string func (s *ServiceBrowser) ViewString() string { - // Header panel (always visible at top) header := s.headerPanel.RenderHome() - if !s.ready { + if !s.vp.Ready { return header + "\n" + "Loading..." } - // Filter input var footer string if s.filterActive { footer = "\n" + s.styles.filterPrompt.Render(s.filterInput.View()) } - return header + "\n" + s.viewport.View() + footer + return header + "\n" + s.vp.Model.View() + footer } // View implements tea.Model @@ -641,22 +627,13 @@ func (s *ServiceBrowser) SetSize(width, height int) tea.Cmd { headerStr := s.headerPanel.RenderHome() headerHeight := s.headerPanel.Height(headerStr) - // height - header + extra space vpHeight := height - headerHeight + 1 if vpHeight < 5 { vpHeight = 5 } - if !s.ready { - s.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(vpHeight)) - s.ready = true - } else { - s.viewport.SetWidth(width) - s.viewport.SetHeight(vpHeight) - } - - // Update viewport content - s.viewport.SetContent(s.renderContent()) + s.vp.SetSize(width, vpHeight) + s.vp.Model.SetContent(s.renderContent()) return nil } diff --git a/internal/view/viewport_helper.go b/internal/view/viewport_helper.go new file mode 100644 index 00000000..0bfc33e1 --- /dev/null +++ b/internal/view/viewport_helper.go @@ -0,0 +1,18 @@ +package view + +import "charm.land/bubbles/v2/viewport" + +type ViewportState struct { + Model viewport.Model + Ready bool +} + +func (vs *ViewportState) SetSize(width, height int) { + if !vs.Ready { + vs.Model = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) + vs.Ready = true + } else { + vs.Model.SetWidth(width) + vs.Model.SetHeight(height) + } +} From 489fea3cd32238ab98d9483032c7e43f197bdbed Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 14:06:08 +0000 Subject: [PATCH 02/12] refactor: use theme style helpers across views (#88) --- internal/ui/theme.go | 1 + internal/view/action_menu.go | 4 ++-- internal/view/dashboard_view.go | 2 +- internal/view/dashboard_view_panels.go | 2 +- internal/view/detail_view.go | 2 +- internal/view/diff_view.go | 2 +- internal/view/help_view.go | 2 +- internal/view/log_view.go | 2 +- internal/view/multi_selector.go | 4 ++-- internal/view/resource_browser.go | 2 +- 10 files changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 451ec6c0..ed97d618 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -102,6 +102,7 @@ func WarningStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Warning) } +// DangerStyle returns a style for danger/error states func DangerStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Danger) } diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index c0b19ad3..b3711de5 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -36,9 +36,9 @@ type actionMenuStyles struct { func newActionMenuStyles() actionMenuStyles { t := ui.Current() return actionMenuStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), + title: ui.TitleStyle(), item: lipgloss.NewStyle().PaddingLeft(2), - selected: lipgloss.NewStyle().PaddingLeft(2).Background(t.Selection).Foreground(t.SelectionText), + selected: ui.SelectedStyle().PaddingLeft(2), shortcut: lipgloss.NewStyle().Foreground(t.Secondary), box: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1).MarginTop(1), dangerBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Danger).Padding(0, 1).MarginTop(1), diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index 8f6a999e..ea657ac4 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -35,7 +35,7 @@ func newDashboardStyles() dashboardStyles { danger: lipgloss.NewStyle().Foreground(t.Danger), success: lipgloss.NewStyle().Foreground(t.Success), dim: lipgloss.NewStyle().Foreground(t.TextMuted), - highlight: lipgloss.NewStyle().Background(t.Selection).Foreground(t.SelectionText), + highlight: ui.SelectedStyle(), } } diff --git a/internal/view/dashboard_view_panels.go b/internal/view/dashboard_view_panels.go index 1d1842bb..490ad556 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -39,7 +39,7 @@ const ( ) func renderPanel(title, content string, width, height int, t *ui.Theme, hovered bool) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Primary) + titleStyle := ui.TitleStyle() boxHeight := height - 1 if boxHeight < 3 { boxHeight = 3 diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 784cd462..059f6ba8 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -27,7 +27,7 @@ type detailViewStyles struct { func newDetailViewStyles() detailViewStyles { t := ui.Current() return detailViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), + title: ui.TitleStyle(), label: lipgloss.NewStyle().Foreground(t.TextDim).Width(15), value: lipgloss.NewStyle().Foreground(t.Text), } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index cae0556f..a588573f 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -36,7 +36,7 @@ type diffViewStyles struct { func newDiffViewStyles() diffViewStyles { t := ui.Current() return diffViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), + title: ui.TitleStyle(), header: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary), content: lipgloss.NewStyle().Foreground(t.Text), separator: lipgloss.NewStyle().Foreground(t.TableBorder), diff --git a/internal/view/help_view.go b/internal/view/help_view.go index feea2f8c..fc7cc5cd 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -19,7 +19,7 @@ type helpViewStyles struct { func newHelpViewStyles() helpViewStyles { t := ui.Current() return helpViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), + title: ui.TitleStyle(), section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), key: lipgloss.NewStyle().Foreground(t.Success).Width(15), desc: lipgloss.NewStyle().Foreground(t.Text), diff --git a/internal/view/log_view.go b/internal/view/log_view.go index b04d24f6..4278739f 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -68,7 +68,7 @@ type logViewStyles struct { func newLogViewStyles() logViewStyles { t := ui.Current() return logViewStyles{ - header: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), + header: ui.TitleStyle(), timestamp: lipgloss.NewStyle().Foreground(t.Secondary), message: lipgloss.NewStyle().Foreground(t.Text), paused: lipgloss.NewStyle().Bold(true).Foreground(t.Warning), diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index 37446597..3e85e75a 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -26,9 +26,9 @@ type selectorStyles struct { func newSelectorStyles() selectorStyles { t := ui.Current() return selectorStyles{ - title: lipgloss.NewStyle().Background(t.TableHeader).Foreground(t.TableHeaderText).Padding(0, 1), + title: ui.TableHeaderStyle().Padding(0, 1), item: lipgloss.NewStyle().PaddingLeft(2), - itemSelected: lipgloss.NewStyle().PaddingLeft(2).Background(t.Selection).Foreground(t.SelectionText), + itemSelected: ui.SelectedStyle().PaddingLeft(2), itemChecked: lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Success), filter: lipgloss.NewStyle().Foreground(t.Accent), } diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 609fbad3..7c477a02 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -43,7 +43,7 @@ func newResourceBrowserStyles() resourceBrowserStyles { filterBg: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), filterActive: lipgloss.NewStyle().Foreground(t.Accent).Italic(true), tabSingle: lipgloss.NewStyle().Foreground(t.Primary), - tabActive: lipgloss.NewStyle().Background(t.Selection).Foreground(t.SelectionText).Padding(0, 1), + tabActive: ui.SelectedStyle().Padding(0, 1), tabInactive: lipgloss.NewStyle().Foreground(t.TextDim).Padding(0, 1), } } From d65030dae7bf109a65a31a2a662d94ea10913f3b Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 14:35:20 +0000 Subject: [PATCH 03/12] refactor: extend theme style helpers to app and render packages (#88) --- internal/app/app.go | 2 +- internal/render/detail.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 18ec69bf..25e11052 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,7 +48,7 @@ type appStyles struct { func newAppStyles(width int) appStyles { t := ui.Current() return appStyles{ - status: lipgloss.NewStyle().Background(t.TableHeader).Foreground(t.TableHeaderText).Padding(0, 1).Width(width), + status: ui.TableHeaderStyle().Padding(0, 1).Width(width), readOnly: lipgloss.NewStyle().Background(t.Warning).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 1), warningTitle: lipgloss.NewStyle().Bold(true).Foreground(t.Pending).MarginBottom(1), warningItem: lipgloss.NewStyle().Foreground(t.Warning), diff --git a/internal/render/detail.go b/internal/render/detail.go index 2e44f9ed..a45d559f 100644 --- a/internal/render/detail.go +++ b/internal/render/detail.go @@ -45,7 +45,7 @@ func DefaultDetailStyles() DetailStyles { } t := ui.Current() styles := DetailStyles{ - Title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), + Title: ui.TitleStyle(), Section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), Label: lipgloss.NewStyle().Foreground(t.TextDim).Width(32), Value: lipgloss.NewStyle().Foreground(t.Text), From cecfab2aed8adfab9b5c0e18788f2a0424885ccc Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 14:51:06 +0000 Subject: [PATCH 04/12] refactor: apply style helpers and Go 1.21+/1.22+ idioms (#88) - service_browser: use TableHeaderStyle() and SelectedStyle() - use max()/min() builtins instead of if statements - use range over int for loop modernization --- internal/view/detail_view.go | 5 +---- internal/view/diff_view.go | 12 +++-------- internal/view/service_browser.go | 35 ++++++++------------------------ 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 059f6ba8..eb24930e 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -205,10 +205,7 @@ func (d *DetailView) SetSize(width, height int) tea.Cmd { headerStr := d.headerPanel.Render(d.service, d.resType, summaryFields) headerHeight := d.headerPanel.Height(headerStr) - viewportHeight := height - headerHeight + 1 - if viewportHeight < 5 { - viewportHeight = 5 - } + viewportHeight := max(height-headerHeight+1, 5) d.vp.SetSize(width, viewportHeight) diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index a588573f..46598c78 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -96,10 +96,7 @@ func (d *DiffView) SetSize(width, height int) tea.Cmd { // Reserve space for header headerHeight := 3 - viewportHeight := height - headerHeight - if viewportHeight < 5 { - viewportHeight = 5 - } + viewportHeight := max(height-headerHeight, 5) d.vp.SetSize(width, viewportHeight) @@ -151,12 +148,9 @@ func (d *DiffView) renderSideBySide() string { out.WriteString("\n") // Render side by side - maxLines := len(leftLines) - if len(rightLines) > maxLines { - maxLines = len(rightLines) - } + maxLines := max(len(leftLines), len(rightLines)) - for i := 0; i < maxLines; i++ { + for i := range maxLines { leftLine := "" rightLine := "" diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 161ab2b1..1351a07f 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -88,9 +88,7 @@ type serviceBrowserStyles struct { func newServiceBrowserStyles() serviceBrowserStyles { t := ui.Current() return serviceBrowserStyles{ - title: lipgloss.NewStyle(). - Background(t.TableHeader). - Foreground(t.TableHeaderText). + title: ui.TableHeaderStyle(). Padding(0, 1). MarginBottom(1), category: lipgloss.NewStyle(). @@ -102,11 +100,10 @@ func newServiceBrowserStyles() serviceBrowserStyles { Width(cellWidth). Height(cellHeight). Padding(0, 1), - cellSelected: lipgloss.NewStyle(). + cellSelected: ui.SelectedStyle(). Width(cellWidth). Height(cellHeight). - Padding(0, 1). - Background(t.Selection), + Padding(0, 1), serviceName: lipgloss.NewStyle(). Bold(true). Foreground(t.Text), @@ -383,12 +380,7 @@ func (s *ServiceBrowser) updateViewport() { s.vp.Model.SetYOffset(max(0, targetLine-2)) } else if targetLine > currentTop+vpHeight-cellHeight { newOffset := targetLine - vpHeight + cellHeight + 2 - if newOffset > totalLines-vpHeight { - newOffset = totalLines - vpHeight - } - if newOffset < 0 { - newOffset = 0 - } + newOffset = max(0, min(newOffset, totalLines-vpHeight)) s.vp.Model.SetYOffset(newOffset) } } @@ -537,9 +529,9 @@ func (s *ServiceBrowser) renderContent() string { // Render services in grid rows := (len(catItems) + s.cols - 1) / s.cols - for row := 0; row < rows; row++ { + for row := range rows { var cells []string - for col := 0; col < s.cols; col++ { + for col := range s.cols { idx := row*s.cols + col if idx < len(catItems) { selected := globalIdx+idx == s.cursor @@ -550,7 +542,7 @@ func (s *ServiceBrowser) renderContent() string { rowHeight := strings.Count(rowContent, "\n") + 1 // +1 for the line after // Record positions for items in this row - for col := 0; col < s.cols; col++ { + for col := range s.cols { idx := row*s.cols + col if idx < len(catItems) { s.itemPositions = append(s.itemPositions, itemPosition{ @@ -615,22 +607,13 @@ func (s *ServiceBrowser) SetSize(width, height int) tea.Cmd { s.headerPanel.SetWidth(width) // Calculate columns based on width - s.cols = (width - cellPaddingX) / cellWidth - if s.cols < minColumns { - s.cols = minColumns - } - if s.cols > maxColumns { - s.cols = maxColumns - } + s.cols = max(minColumns, min((width-cellPaddingX)/cellWidth, maxColumns)) // Calculate header height dynamically headerStr := s.headerPanel.RenderHome() headerHeight := s.headerPanel.Height(headerStr) - vpHeight := height - headerHeight + 1 - if vpHeight < 5 { - vpHeight = 5 - } + vpHeight := max(height-headerHeight+1, 5) s.vp.SetSize(width, vpHeight) s.vp.Model.SetContent(s.renderContent()) From cf93e3c2dc2a781ba5d68f5d446e159486f31bf0 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 15:50:01 +0000 Subject: [PATCH 05/12] refactor: complete style helpers and Go 1.21+ idioms (#88) - Add 6 new theme helpers: SectionStyle, HighlightStyle, BoldSuccessStyle, BoldDangerStyle, BoldWarningStyle, BoldPendingStyle - Apply helpers across 8 view files for consistency - Fix hardcoded #ff0000 color in exec_with_header.go - Use max()/min() builtins in dashboard_view_panels.go - Use range-over-int in dashboard_view.go and dashboard_view_panels.go --- internal/action/exec_with_header.go | 2 +- internal/app/app.go | 2 +- internal/render/detail.go | 2 +- internal/ui/theme.go | 24 ++++++++++++++ internal/view/action_menu.go | 6 ++-- internal/view/command_input.go | 2 +- internal/view/dashboard_view.go | 4 +-- internal/view/dashboard_view_panels.go | 45 ++++++++------------------ internal/view/diff_view.go | 2 +- internal/view/header_panel.go | 2 +- internal/view/help_view.go | 2 +- internal/view/log_view.go | 2 +- 12 files changed, 50 insertions(+), 45 deletions(-) diff --git a/internal/action/exec_with_header.go b/internal/action/exec_with_header.go index 720bce37..3036f51a 100644 --- a/internal/action/exec_with_header.go +++ b/internal/action/exec_with_header.go @@ -186,7 +186,7 @@ func (e *ExecWithHeader) Run() error { // If command failed, show error and wait for keypress if err != nil { - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")).Bold(true) + errorStyle := ui.BoldDangerStyle() _, _ = fmt.Fprintln(stdout) _, _ = fmt.Fprintln(stdout, errorStyle.Render("Command failed: ")+err.Error()) _, _ = fmt.Fprintln(stdout) diff --git a/internal/app/app.go b/internal/app/app.go index 25e11052..cc89ea1e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -50,7 +50,7 @@ func newAppStyles(width int) appStyles { return appStyles{ status: ui.TableHeaderStyle().Padding(0, 1).Width(width), readOnly: lipgloss.NewStyle().Background(t.Warning).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 1), - warningTitle: lipgloss.NewStyle().Bold(true).Foreground(t.Pending).MarginBottom(1), + warningTitle: ui.BoldPendingStyle().MarginBottom(1), warningItem: lipgloss.NewStyle().Foreground(t.Warning), warningDim: lipgloss.NewStyle().Foreground(t.TextDim).MarginTop(1), warningBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Pending).Padding(1, 2), diff --git a/internal/render/detail.go b/internal/render/detail.go index a45d559f..732fd230 100644 --- a/internal/render/detail.go +++ b/internal/render/detail.go @@ -46,7 +46,7 @@ func DefaultDetailStyles() DetailStyles { t := ui.Current() styles := DetailStyles{ Title: ui.TitleStyle(), - Section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), + Section: ui.SectionStyle().MarginTop(1), Label: lipgloss.NewStyle().Foreground(t.TextDim).Width(32), Value: lipgloss.NewStyle().Foreground(t.Text), Dim: lipgloss.NewStyle().Foreground(t.TextDim), diff --git a/internal/ui/theme.go b/internal/ui/theme.go index ed97d618..1c6b7918 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -119,6 +119,30 @@ func TableHeaderStyle() lipgloss.Style { return lipgloss.NewStyle().Background(current.TableHeader).Foreground(current.TableHeaderText) } +func SectionStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Secondary) +} + +func HighlightStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Accent) +} + +func BoldSuccessStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Success) +} + +func BoldDangerStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Danger) +} + +func BoldWarningStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Warning) +} + +func BoldPendingStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Pending) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index b3711de5..9d3d175b 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -42,8 +42,8 @@ func newActionMenuStyles() actionMenuStyles { shortcut: lipgloss.NewStyle().Foreground(t.Secondary), box: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1).MarginTop(1), dangerBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Danger).Padding(0, 1).MarginTop(1), - yes: lipgloss.NewStyle().Bold(true).Foreground(t.Success), - no: lipgloss.NewStyle().Bold(true).Foreground(t.Danger), + yes: ui.BoldSuccessStyle(), + no: ui.BoldDangerStyle(), bold: lipgloss.NewStyle().Bold(true), input: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(t.Border).Padding(0, 1), } @@ -343,7 +343,7 @@ func (m *ActionMenu) renderDangerousConfirm(act action.Action) string { s := m.styles t := ui.Current() - dangerTitle := lipgloss.NewStyle().Bold(true).Foreground(t.Danger).Render("⚠ DANGER") + dangerTitle := ui.BoldDangerStyle().Render("⚠ DANGER") content := dangerTitle + "\n\n" content += fmt.Sprintf("You are about to %s:\n", s.no.Render(act.Name)) content += s.bold.Render(m.dangerous.token) + "\n\n" diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 1c29a76b..453cfd39 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -30,7 +30,7 @@ func newCommandInputStyles() commandInputStyles { return commandInputStyles{ input: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), suggestion: lipgloss.NewStyle().Foreground(t.TextDim), - highlight: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), + highlight: ui.HighlightStyle(), } } diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index ea657ac4..9f016761 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -246,7 +246,7 @@ func (d *DashboardView) computeRowFromContentLine(panelIdx, lineY int) int { line := 0 if len(d.alarms) > 0 { line++ - for i := 0; i < len(d.alarms); i++ { + for i := range d.alarms { if lineY == line { return i } @@ -258,7 +258,7 @@ func (d *DashboardView) computeRowFromContentLine(panelIdx, lineY int) int { if len(d.healthItems) > 0 { line++ alarmCount := len(d.alarms) - for i := 0; i < len(d.healthItems); i++ { + for i := range d.healthItems { if lineY == line { return alarmCount + i } diff --git a/internal/view/dashboard_view_panels.go b/internal/view/dashboard_view_panels.go index 490ad556..ced8b234 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -40,10 +40,7 @@ const ( func renderPanel(title, content string, width, height int, t *ui.Theme, hovered bool) string { titleStyle := ui.TitleStyle() - boxHeight := height - 1 - if boxHeight < 3 { - boxHeight = 3 - } + boxHeight := max(height-1, 3) borderColor := t.Border if hovered { @@ -62,21 +59,12 @@ func renderPanel(title, content string, width, height int, t *ui.Theme, hovered borderStyle.Render(content)) } -func renderBar(value, max float64, width int, t *ui.Theme) string { - if max <= 0 || width <= 0 { +func renderBar(value, maxVal float64, width int, t *ui.Theme) string { + if maxVal <= 0 || width <= 0 { return "" } - ratio := value / max - if ratio > 1 { - ratio = 1 - } - filled := int(ratio * float64(width)) - if filled < 0 { - filled = 0 - } - if filled > width { - filled = width - } + ratio := min(value/maxVal, 1.0) + filled := min(max(int(ratio*float64(width)), 0), width) barStyle := lipgloss.NewStyle().Foreground(t.Accent) emptyStyle := lipgloss.NewStyle().Foreground(t.TextMuted) @@ -101,19 +89,12 @@ func (d *DashboardView) renderCostContent(contentWidth, contentHeight int, t *ui available := contentWidth - costValueWidth - costPadding nameWidth := available * costNameWidthRatio / 100 barWidth := available - nameWidth - if nameWidth < minCostNameWidth { - nameWidth = minCostNameWidth - } - if barWidth < minCostBarWidth { - barWidth = minCostBarWidth - } - maxServices := contentHeight - 2 - if maxServices < 3 { - maxServices = 3 - } + nameWidth = max(nameWidth, minCostNameWidth) + barWidth = max(barWidth, minCostBarWidth) + maxServices := max(contentHeight-2, 3) showCount := min(len(d.costTop), maxServices) - for i := 0; i < showCount; i++ { + for i := range showCount { c := d.costTop[i] bar := renderBar(c.cost, maxCost, barWidth, t) name := truncateValue(c.service, nameWidth) @@ -151,7 +132,7 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR } else if alarmCount > 0 { lines = append(lines, s.danger.Render(fmt.Sprintf("Alarms: %d in ALARM", alarmCount))) maxShow := min(alarmCount, contentHeight-3) - for i := 0; i < maxShow; i++ { + for i := range maxShow { line := " " + s.danger.Render("• ") + truncateValue(d.alarms[i].name, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) @@ -170,7 +151,7 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR lines = append(lines, s.warning.Render(fmt.Sprintf("Health: %d open", len(d.healthItems)))) remaining := contentHeight - len(lines) - 1 maxShow := min(len(d.healthItems), remaining) - for i := 0; i < maxShow; i++ { + for i := range maxShow { h := d.healthItems[i] line := " " + s.warning.Render("• ") + truncateValue(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) if alarmCount+i == focusRow { @@ -209,7 +190,7 @@ func (d *DashboardView) renderSecurityContent(contentWidth, contentHeight int, f lines = append(lines, s.warning.Render(fmt.Sprintf("High: %d 🟠", high))) } maxShow := min(len(d.secItems), contentHeight-len(lines)-1) - for i := 0; i < maxShow; i++ { + for i := range maxShow { item := d.secItems[i] style := s.warning if item.severity == "CRITICAL" { @@ -256,7 +237,7 @@ func (d *DashboardView) renderOptimizationContent(contentWidth, contentHeight in } if len(d.taItems) > 0 { maxShow := min(len(d.taItems), contentHeight-len(lines)-1) - for i := 0; i < maxShow; i++ { + for i := range maxShow { item := d.taItems[i] style := s.warning if item.status == "error" { diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 46598c78..fc27985d 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -37,7 +37,7 @@ func newDiffViewStyles() diffViewStyles { t := ui.Current() return diffViewStyles{ title: ui.TitleStyle(), - header: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary), + header: ui.SectionStyle(), content: lipgloss.NewStyle().Foreground(t.Text), separator: lipgloss.NewStyle().Foreground(t.TableBorder), } diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index 21bc34f2..afb98212 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -48,7 +48,7 @@ func newHeaderPanelStyles() headerPanelStyles { panel: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), label: lipgloss.NewStyle().Foreground(t.TextDim), value: lipgloss.NewStyle().Foreground(t.Text), - accent: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), + accent: ui.HighlightStyle(), dim: lipgloss.NewStyle().Foreground(t.TextMuted), separator: lipgloss.NewStyle().Foreground(t.Border), } diff --git a/internal/view/help_view.go b/internal/view/help_view.go index fc7cc5cd..8f6185ae 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -20,7 +20,7 @@ func newHelpViewStyles() helpViewStyles { t := ui.Current() return helpViewStyles{ title: ui.TitleStyle(), - section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), + section: ui.SectionStyle().MarginTop(1), key: lipgloss.NewStyle().Foreground(t.Success).Width(15), desc: lipgloss.NewStyle().Foreground(t.Text), } diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 4278739f..fbd462ea 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -71,7 +71,7 @@ func newLogViewStyles() logViewStyles { header: ui.TitleStyle(), timestamp: lipgloss.NewStyle().Foreground(t.Secondary), message: lipgloss.NewStyle().Foreground(t.Text), - paused: lipgloss.NewStyle().Bold(true).Foreground(t.Warning), + paused: ui.BoldWarningStyle(), error: lipgloss.NewStyle().Foreground(t.Danger), dim: lipgloss.NewStyle().Foreground(t.TextDim), } From fe7814c5fdac32aad14b5b4dcd3cc4bb8bb1d5dd Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 16:08:43 +0000 Subject: [PATCH 06/12] refactor: remove dead code and expand style helper usage (#88) - Remove unused width/height fields from 5 view structs - Apply DimStyle() across 11 locations - Apply SuccessStyle/WarningStyle/DangerStyle where applicable - Update tests to match field removal --- internal/view/command_input.go | 4 +--- internal/view/command_input_test.go | 4 ---- internal/view/dashboard_view.go | 6 +++--- internal/view/detail_view.go | 8 +------- internal/view/diff_view.go | 2 -- internal/view/diff_view_test.go | 3 --- internal/view/header_panel.go | 2 +- internal/view/help_view.go | 7 +------ internal/view/log_view.go | 9 ++------- internal/view/multi_selector.go | 2 +- internal/view/profile_selector.go | 5 ++--- internal/view/resource_browser.go | 4 ++-- internal/view/service_browser.go | 9 +++------ 13 files changed, 17 insertions(+), 48 deletions(-) diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 453cfd39..663e0ba2 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -29,7 +29,7 @@ func newCommandInputStyles() commandInputStyles { t := ui.Current() return commandInputStyles{ input: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), - suggestion: lipgloss.NewStyle().Foreground(t.TextDim), + suggestion: ui.DimStyle(), highlight: ui.HighlightStyle(), } } @@ -55,7 +55,6 @@ type CommandInput struct { registry *registry.Registry textInput textinput.Model active bool - width int suggestions []string suggIdx int styles commandInputStyles @@ -195,7 +194,6 @@ func (c *CommandInput) View() string { // SetWidth sets the input width func (c *CommandInput) SetWidth(width int) { - c.width = width c.textInput.SetWidth(width - 4) } diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index a5643b17..36b329de 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -140,10 +140,6 @@ func TestCommandInput_SetWidth(t *testing.T) { ci := NewCommandInput(ctx, reg) ci.SetWidth(100) - - if ci.width != 100 { - t.Errorf("width = %d, want 100", ci.width) - } } func TestCommandInput_Update_Esc(t *testing.T) { diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index 9f016761..583c508a 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -31,9 +31,9 @@ type dashboardStyles struct { func newDashboardStyles() dashboardStyles { t := ui.Current() return dashboardStyles{ - warning: lipgloss.NewStyle().Foreground(t.Warning), - danger: lipgloss.NewStyle().Foreground(t.Danger), - success: lipgloss.NewStyle().Foreground(t.Success), + warning: ui.WarningStyle(), + danger: ui.DangerStyle(), + success: ui.SuccessStyle(), dim: lipgloss.NewStyle().Foreground(t.TextMuted), highlight: ui.SelectedStyle(), } diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index eb24930e..1c7b5e6e 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -28,7 +28,7 @@ func newDetailViewStyles() detailViewStyles { t := ui.Current() return detailViewStyles{ title: ui.TitleStyle(), - label: lipgloss.NewStyle().Foreground(t.TextDim).Width(15), + label: ui.DimStyle().Width(15), value: lipgloss.NewStyle().Foreground(t.Text), } } @@ -41,8 +41,6 @@ type DetailView struct { resType string vp ViewportState headerPanel *HeaderPanel - width int - height int registry *registry.Registry dao dao.DAO refreshing bool @@ -191,10 +189,6 @@ func (d *DetailView) View() tea.View { // SetSize implements View func (d *DetailView) SetSize(width, height int) tea.Cmd { - d.width = width - d.height = height - - // Set header panel width d.headerPanel.SetWidth(width) // Calculate header height dynamically diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index fc27985d..61f18dbf 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -22,7 +22,6 @@ type DiffView struct { resourceType string vp ViewportState width int - height int styles diffViewStyles } @@ -92,7 +91,6 @@ func (d *DiffView) View() tea.View { // SetSize implements View func (d *DiffView) SetSize(width, height int) tea.Cmd { d.width = width - d.height = height // Reserve space for header headerHeight := 3 diff --git a/internal/view/diff_view_test.go b/internal/view/diff_view_test.go index 19261a00..f7e65494 100644 --- a/internal/view/diff_view_test.go +++ b/internal/view/diff_view_test.go @@ -61,9 +61,6 @@ func TestDiffView_SetSize(t *testing.T) { if dv.width != 100 { t.Errorf("width = %d, want 100", dv.width) } - if dv.height != 50 { - t.Errorf("height = %d, want 50", dv.height) - } } func TestDiffView_Update_Esc(t *testing.T) { diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index afb98212..13a9755a 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -46,7 +46,7 @@ func newHeaderPanelStyles() headerPanelStyles { t := ui.Current() return headerPanelStyles{ panel: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), - label: lipgloss.NewStyle().Foreground(t.TextDim), + label: ui.DimStyle(), value: lipgloss.NewStyle().Foreground(t.Text), accent: ui.HighlightStyle(), dim: lipgloss.NewStyle().Foreground(t.TextMuted), diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 8f6185ae..0c7e5ce3 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -21,14 +21,12 @@ func newHelpViewStyles() helpViewStyles { return helpViewStyles{ title: ui.TitleStyle(), section: ui.SectionStyle().MarginTop(1), - key: lipgloss.NewStyle().Foreground(t.Success).Width(15), + key: ui.SuccessStyle().Width(15), desc: lipgloss.NewStyle().Foreground(t.Text), } } type HelpView struct { - width int - height int styles helpViewStyles vp ViewportState } @@ -166,9 +164,6 @@ func (h *HelpView) View() tea.View { } func (h *HelpView) SetSize(width, height int) tea.Cmd { - h.width = width - h.height = height - h.vp.SetSize(width, height) h.vp.Model.SetContent(h.renderContent()) return nil diff --git a/internal/view/log_view.go b/internal/view/log_view.go index fbd462ea..e21ef7f1 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -43,8 +43,6 @@ type LogView struct { loading bool paused bool err error - width int - height int lastEventTime int64 oldestEventTime int64 @@ -72,8 +70,8 @@ func newLogViewStyles() logViewStyles { timestamp: lipgloss.NewStyle().Foreground(t.Secondary), message: lipgloss.NewStyle().Foreground(t.Text), paused: ui.BoldWarningStyle(), - error: lipgloss.NewStyle().Foreground(t.Danger), - dim: lipgloss.NewStyle().Foreground(t.TextDim), + error: ui.DangerStyle(), + dim: ui.DimStyle(), } } @@ -402,10 +400,7 @@ func (v *LogView) View() tea.View { } func (v *LogView) SetSize(width, height int) tea.Cmd { - v.width = width - v.height = height viewportHeight := height - viewportHeaderOffset - v.vp.SetSize(width, viewportHeight) v.updateViewportContent() return nil diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index 3e85e75a..c1a912a6 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -29,7 +29,7 @@ func newSelectorStyles() selectorStyles { title: ui.TableHeaderStyle().Padding(0, 1), item: lipgloss.NewStyle().PaddingLeft(2), itemSelected: ui.SelectedStyle().PaddingLeft(2), - itemChecked: lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Success), + itemChecked: ui.SuccessStyle().PaddingLeft(2), filter: lipgloss.NewStyle().Foreground(t.Accent), } } diff --git a/internal/view/profile_selector.go b/internal/view/profile_selector.go index 7e3a22e9..ced66b17 100644 --- a/internal/view/profile_selector.go +++ b/internal/view/profile_selector.go @@ -44,12 +44,11 @@ func NewProfileSelector() *ProfileSelector { initialSelected = append(initialSelected, sel.ID()) } - t := ui.Current() p := &ProfileSelector{ selector: NewMultiSelector[profileItem]("Select Profiles", initialSelected), profileInfo: make(map[string]aws.ProfileInfo), - typeStyle: lipgloss.NewStyle().Foreground(t.TextDim), - regionStyle: lipgloss.NewStyle().Foreground(t.TextDim), + typeStyle: ui.DimStyle(), + regionStyle: ui.DimStyle(), } p.selector.SetRenderExtra(func(item profileItem) string { diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 7c477a02..b5943a00 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -39,12 +39,12 @@ type resourceBrowserStyles struct { func newResourceBrowserStyles() resourceBrowserStyles { t := ui.Current() return resourceBrowserStyles{ - count: lipgloss.NewStyle().Foreground(t.TextDim), + count: ui.DimStyle(), filterBg: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), filterActive: lipgloss.NewStyle().Foreground(t.Accent).Italic(true), tabSingle: lipgloss.NewStyle().Foreground(t.Primary), tabActive: ui.SelectedStyle().Padding(0, 1), - tabInactive: lipgloss.NewStyle().Foreground(t.TextDim).Padding(0, 1), + tabInactive: ui.DimStyle().Padding(0, 1), } } diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 1351a07f..7307d5cc 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -91,8 +91,7 @@ func newServiceBrowserStyles() serviceBrowserStyles { title: ui.TableHeaderStyle(). Padding(0, 1). MarginBottom(1), - category: lipgloss.NewStyle(). - Foreground(t.TextDim). + category: ui.DimStyle(). Bold(true). MarginTop(1). MarginBottom(0), @@ -110,10 +109,8 @@ func newServiceBrowserStyles() serviceBrowserStyles { serviceNameSe: lipgloss.NewStyle(). Bold(true). Foreground(t.Primary), - aliases: lipgloss.NewStyle(). - Foreground(t.TextDim), - aliasesSel: lipgloss.NewStyle(). - Foreground(t.TextDim), + aliases: ui.DimStyle(), + aliasesSel: ui.DimStyle(), filterPrompt: lipgloss.NewStyle(). Foreground(t.Primary), } From 3e98edb608c44835e4475676d3e9b214b4d9aa0a Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 16:18:03 +0000 Subject: [PATCH 07/12] refactor: add MutedStyle, LoadingMessage const, cmp.Or, remove dead code (#88) --- internal/ui/theme.go | 5 +++++ internal/view/dashboard_view.go | 3 +-- internal/view/detail_view.go | 4 ++-- internal/view/diff_view.go | 2 +- internal/view/header_panel.go | 24 +++++------------------- internal/view/help_view.go | 2 +- internal/view/log_view.go | 2 +- internal/view/multi_selector.go | 2 +- internal/view/service_browser.go | 10 ++++------ internal/view/view.go | 3 +++ 10 files changed, 24 insertions(+), 33 deletions(-) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 1c6b7918..2e2fc98b 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -143,6 +143,11 @@ func BoldPendingStyle() lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(current.Pending) } +// MutedStyle returns a style for very dim/muted text +func MutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.TextMuted) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index 583c508a..208ca364 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -29,12 +29,11 @@ type dashboardStyles struct { } func newDashboardStyles() dashboardStyles { - t := ui.Current() return dashboardStyles{ warning: ui.WarningStyle(), danger: ui.DangerStyle(), success: ui.SuccessStyle(), - dim: lipgloss.NewStyle().Foreground(t.TextMuted), + dim: ui.MutedStyle(), highlight: ui.SelectedStyle(), } } diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 1c7b5e6e..f7bf3bf9 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -169,7 +169,7 @@ func (d *DetailView) handleNavigation(key string) (tea.Model, tea.Cmd) { func (d *DetailView) ViewString() string { if !d.vp.Ready { - return "Loading..." + return LoadingMessage } var summaryFields []render.SummaryField @@ -261,7 +261,7 @@ func (d *DetailView) renderContent() string { // Match placeholders only at line endings to avoid replacing substrings // (e.g., "Not configured server" should not be replaced). if d.refreshing && detail != "" { - loading := ui.DimStyle().Render("Loading...") + loading := ui.DimStyle().Render(LoadingMessage) // Replace placeholders at end of line or end of content for _, placeholder := range []string{render.NotConfigured, render.Empty, render.NoValue} { diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 61f18dbf..a9488716 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -77,7 +77,7 @@ func (d *DiffView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (d *DiffView) ViewString() string { if !d.vp.Ready { - return "Loading..." + return LoadingMessage } return d.vp.Model.View() diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index 13a9755a..3ba9884f 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -1,6 +1,7 @@ package view import ( + "cmp" "strconv" "strings" @@ -49,7 +50,7 @@ func newHeaderPanelStyles() headerPanelStyles { label: ui.DimStyle(), value: lipgloss.NewStyle().Foreground(t.Text), accent: ui.HighlightStyle(), - dim: lipgloss.NewStyle().Foreground(t.TextMuted), + dim: ui.MutedStyle(), separator: lipgloss.NewStyle().Foreground(t.Border), } } @@ -77,19 +78,11 @@ func (h *HeaderPanel) renderContextLine(service, resourceType string) string { accountDisplay = formatMultiAccounts(selections, cfg.AccountIDs()) } else { profileDisplay = cfg.Selection().DisplayName() - accountDisplay = cfg.AccountID() - if accountDisplay == "" { - accountDisplay = "-" - } + accountDisplay = cmp.Or(cfg.AccountID(), "-") } - var regionDisplay string regions := cfg.Regions() - if len(regions) == 0 { - regionDisplay = "-" - } else { - regionDisplay = strings.Join(regions, ", ") - } + regionDisplay := cmp.Or(strings.Join(regions, ", "), "-") line := s.label.Render("Profile: ") + s.value.Render(profileDisplay) + s.dim.Render(" │ ") + @@ -118,7 +111,7 @@ func formatMultiProfiles(selections []config.ProfileSelection) string { return strings.Join(names, ", ") } names := make([]string, maxShow) - for i := 0; i < maxShow; i++ { + for i := range maxShow { names[i] = selections[i].DisplayName() } return strings.Join(names, ", ") + " (+" + strconv.Itoa(len(selections)-maxShow) + ")" @@ -141,13 +134,6 @@ func formatMultiAccounts(selections []config.ProfileSelection, accountIDs map[st return strings.Join(accounts[:maxShow], ", ") + " (+" + strconv.Itoa(len(accounts)-maxShow) + ")" } -// RenderContextLine renders the AWS account/region context line. -// Can be used standalone by other views. -func RenderContextLine(service, resourceType string) string { - h := &HeaderPanel{styles: newHeaderPanelStyles()} - return h.renderContextLine(service, resourceType) -} - // SetWidth sets the panel width func (h *HeaderPanel) SetWidth(width int) { h.width = width diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 0c7e5ce3..420592ad 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -153,7 +153,7 @@ func (h *HelpView) renderContent() string { func (h *HelpView) ViewString() string { if !h.vp.Ready { - return "Loading..." + return LoadingMessage } return h.vp.Model.View() } diff --git a/internal/view/log_view.go b/internal/view/log_view.go index e21ef7f1..34e38a7d 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -356,7 +356,7 @@ func (v *LogView) updateViewportContent() { func (v *LogView) ViewString() string { if !v.vp.Ready { - return "Loading..." + return LoadingMessage } var sb strings.Builder diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index c1a912a6..a9c7af51 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -353,7 +353,7 @@ func (m *MultiSelector[T]) ViewString() string { } if !m.vp.Ready { - return title + "\n" + filterView + "Loading..." + return title + "\n" + filterView + LoadingMessage } return title + "\n" + filterView + m.vp.Model.View() diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 7307d5cc..b3f765fc 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -106,11 +106,9 @@ func newServiceBrowserStyles() serviceBrowserStyles { serviceName: lipgloss.NewStyle(). Bold(true). Foreground(t.Text), - serviceNameSe: lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary), - aliases: ui.DimStyle(), - aliasesSel: ui.DimStyle(), + serviceNameSe: ui.TitleStyle(), + aliases: ui.DimStyle(), + aliasesSel: ui.DimStyle(), filterPrompt: lipgloss.NewStyle(). Foreground(t.Primary), } @@ -471,7 +469,7 @@ func (s *ServiceBrowser) ViewString() string { header := s.headerPanel.RenderHome() if !s.vp.Ready { - return header + "\n" + "Loading..." + return header + "\n" + LoadingMessage } var footer string diff --git a/internal/view/view.go b/internal/view/view.go index adbd8470..96edbe00 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -19,6 +19,9 @@ const DefaultAutoReloadInterval = 3 * time.Second // FilterPlaceholder is the placeholder text for filter inputs const FilterPlaceholder = "filter..." +// LoadingMessage is the standard message shown while loading +const LoadingMessage = "Loading..." + // View is the interface for all views in the application type View interface { tea.Model From 723d21844f37ed4c467e8c9835af883c920b2c0f Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 16:30:19 +0000 Subject: [PATCH 08/12] refactor: add AccentStyle, slices.Collect, remove dead fields (#88) --- internal/action/exec_with_header.go | 5 ++--- internal/ui/theme.go | 5 +++++ internal/view/dashboard_view_panels.go | 4 ++-- internal/view/detail_view_test.go | 14 +++++++------- internal/view/diff_view_test.go | 4 ++-- internal/view/multi_selector.go | 3 +-- internal/view/resource_browser.go | 11 +++-------- internal/view/service_browser.go | 5 ----- internal/view/tag_search_view.go | 24 ++++++++---------------- 9 files changed, 30 insertions(+), 45 deletions(-) diff --git a/internal/action/exec_with_header.go b/internal/action/exec_with_header.go index 3036f51a..4fd88914 100644 --- a/internal/action/exec_with_header.go +++ b/internal/action/exec_with_header.go @@ -1,6 +1,7 @@ package action import ( + "cmp" "fmt" "io" "os" @@ -18,9 +19,7 @@ import ( func setAWSEnv(cmd *exec.Cmd, region string) { cfg := config.Global() - if region == "" { - region = cfg.Region() - } + region = cmp.Or(region, cfg.Region()) cmd.Env = aws.BuildSubprocessEnv(cmd.Env, cfg.Selection(), region) } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 2e2fc98b..7975efec 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -143,6 +143,11 @@ func BoldPendingStyle() lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(current.Pending) } +// AccentStyle returns a style for accent-colored text (non-bold) +func AccentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Accent) +} + // MutedStyle returns a style for very dim/muted text func MutedStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.TextMuted) diff --git a/internal/view/dashboard_view_panels.go b/internal/view/dashboard_view_panels.go index ced8b234..2cb1ca22 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -66,8 +66,8 @@ func renderBar(value, maxVal float64, width int, t *ui.Theme) string { ratio := min(value/maxVal, 1.0) filled := min(max(int(ratio*float64(width)), 0), width) - barStyle := lipgloss.NewStyle().Foreground(t.Accent) - emptyStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + barStyle := ui.AccentStyle() + emptyStyle := ui.MutedStyle() return barStyle.Render(strings.Repeat("█", filled)) + emptyStyle.Render(strings.Repeat("░", width-filled)) diff --git a/internal/view/detail_view_test.go b/internal/view/detail_view_test.go index c4cf4b40..80222a56 100644 --- a/internal/view/detail_view_test.go +++ b/internal/view/detail_view_test.go @@ -155,28 +155,28 @@ func TestDetailViewLoadingPlaceholderReplacement(t *testing.T) { name: "refreshing replaces NotConfigured at line end", detail: "Status: " + render.NotConfigured + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured}, }, { name: "refreshing replaces Empty at line end", detail: "Items: " + render.Empty + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.Empty}, }, { name: "refreshing replaces NoValue at line end", detail: "Comment: " + render.NoValue + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NoValue}, }, { name: "refreshing replaces placeholder at EOF without newline", detail: "Status: " + render.NotConfigured, refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured}, }, { @@ -197,21 +197,21 @@ func TestDetailViewLoadingPlaceholderReplacement(t *testing.T) { name: "refreshing replaces multiple different placeholders", detail: "Status: " + render.NotConfigured + "\nItems: " + render.Empty + "\nComment: " + render.NoValue + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured, render.Empty, render.NoValue}, }, { name: "refreshing replaces multiple same placeholders", detail: "Status: " + render.NotConfigured + "\nEncryption: " + render.NotConfigured + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured}, }, { name: "refreshing replaces consecutive placeholders", detail: "Status: " + render.NotConfigured + "\n" + render.NoValue + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured, render.NoValue}, }, { diff --git a/internal/view/diff_view_test.go b/internal/view/diff_view_test.go index f7e65494..6ef1ef4c 100644 --- a/internal/view/diff_view_test.go +++ b/internal/view/diff_view_test.go @@ -112,7 +112,7 @@ func TestDiffView_View_NotReady(t *testing.T) { // Without SetSize, should show loading view := dv.ViewString() - if view != "Loading..." { - t.Errorf("ViewString() = %q, want 'Loading...'", view) + if view != LoadingMessage { + t.Errorf("ViewString() = %q, want %q", view, LoadingMessage) } } diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index a9c7af51..b764f5e7 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -24,13 +24,12 @@ type selectorStyles struct { } func newSelectorStyles() selectorStyles { - t := ui.Current() return selectorStyles{ title: ui.TableHeaderStyle().Padding(0, 1), item: lipgloss.NewStyle().PaddingLeft(2), itemSelected: ui.SelectedStyle().PaddingLeft(2), itemChecked: ui.SuccessStyle().PaddingLeft(2), - filter: lipgloss.NewStyle().Foreground(t.Accent), + filter: ui.AccentStyle(), } } diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index b5943a00..bac5c4b6 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "maps" "slices" "strings" "time" @@ -407,10 +408,7 @@ func (r *ResourceBrowser) GetTagKeys() []string { } } - keys := make([]string, 0, len(keySet)) - for key := range keySet { - keys = append(keys, key) - } + keys := slices.Collect(maps.Keys(keySet)) slices.Sort(keys) return keys } @@ -432,10 +430,7 @@ func (r *ResourceBrowser) GetTagValues(key string) []string { } } - values := make([]string, 0, len(valueSet)) - for value := range valueSet { - values = append(values, value) - } + values := slices.Collect(maps.Keys(valueSet)) slices.Sort(values) return values } diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index b3f765fc..bbcaeb6f 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -39,8 +39,6 @@ type ServiceBrowser struct { cursor int // Current selection index in flatItems cols int // Number of columns in grid - width int - height int // Mouse hit testing - populated during render itemPositions []itemPosition @@ -595,9 +593,6 @@ func (s *ServiceBrowser) renderCell(item serviceItem, selected bool) string { // SetSize implements View func (s *ServiceBrowser) SetSize(width, height int) tea.Cmd { - s.width = width - s.height = height - // Set header panel width s.headerPanel.SetWidth(width) diff --git a/internal/view/tag_search_view.go b/internal/view/tag_search_view.go index fba34a59..2eb0acac 100644 --- a/internal/view/tag_search_view.go +++ b/internal/view/tag_search_view.go @@ -3,7 +3,8 @@ package view import ( "context" "fmt" - "sort" + "maps" + "slices" "strings" "sync" @@ -683,11 +684,8 @@ func (v *TagSearchView) GetTagKeys() []string { } } - keys := make([]string, 0, len(keySet)) - for key := range keySet { - keys = append(keys, key) - } - sort.Strings(keys) + keys := slices.Collect(maps.Keys(keySet)) + slices.Sort(keys) return keys } @@ -703,11 +701,8 @@ func (v *TagSearchView) GetTagValues(key string) []string { } } - values := make([]string, 0, len(valueSet)) - for val := range valueSet { - values = append(values, val) - } - sort.Strings(values) + values := slices.Collect(maps.Keys(valueSet)) + slices.Sort(values) return values } @@ -716,11 +711,8 @@ func formatTags(tags map[string]string, maxLen int) string { return "" } - keys := make([]string, 0, len(tags)) - for k := range tags { - keys = append(keys, k) - } - sort.Strings(keys) + keys := slices.Collect(maps.Keys(tags)) + slices.Sort(keys) var parts []string for _, k := range keys { From 042315c4e15e4f489b30f3aacdabc8b0ecf989b1 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 16:42:58 +0000 Subject: [PATCH 09/12] refactor: add TextStyle/SecondaryStyle/BorderStyle/PrimaryStyle, remove dead code (#88) --- internal/ui/theme.go | 20 ++++++++++++++++++++ internal/view/action_menu.go | 2 +- internal/view/detail_view.go | 3 +-- internal/view/diff_view.go | 2 -- internal/view/header_panel.go | 4 ++-- internal/view/help_view.go | 3 +-- internal/view/log_view.go | 5 ++--- internal/view/service_browser.go | 12 ++---------- 8 files changed, 29 insertions(+), 22 deletions(-) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 7975efec..eb244ca4 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -153,6 +153,26 @@ func MutedStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.TextMuted) } +// TextStyle returns a style for normal text +func TextStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Text) +} + +// SecondaryStyle returns a style for secondary-colored text +func SecondaryStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Secondary) +} + +// BorderStyle returns a style for border-colored text (separators) +func BorderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Border) +} + +// PrimaryStyle returns a style for primary-colored text (non-bold) +func PrimaryStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Primary) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index 9d3d175b..b499563f 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -39,7 +39,7 @@ func newActionMenuStyles() actionMenuStyles { title: ui.TitleStyle(), item: lipgloss.NewStyle().PaddingLeft(2), selected: ui.SelectedStyle().PaddingLeft(2), - shortcut: lipgloss.NewStyle().Foreground(t.Secondary), + shortcut: ui.SecondaryStyle(), box: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1).MarginTop(1), dangerBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Danger).Padding(0, 1).MarginTop(1), yes: ui.BoldSuccessStyle(), diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index f7bf3bf9..279dcde3 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -25,11 +25,10 @@ type detailViewStyles struct { } func newDetailViewStyles() detailViewStyles { - t := ui.Current() return detailViewStyles{ title: ui.TitleStyle(), label: ui.DimStyle().Width(15), - value: lipgloss.NewStyle().Foreground(t.Text), + value: ui.TextStyle(), } } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index a9488716..3b8a83dc 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -28,7 +28,6 @@ type DiffView struct { type diffViewStyles struct { title lipgloss.Style header lipgloss.Style - content lipgloss.Style separator lipgloss.Style } @@ -37,7 +36,6 @@ func newDiffViewStyles() diffViewStyles { return diffViewStyles{ title: ui.TitleStyle(), header: ui.SectionStyle(), - content: lipgloss.NewStyle().Foreground(t.Text), separator: lipgloss.NewStyle().Foreground(t.TableBorder), } } diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index 3ba9884f..54fd726f 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -48,10 +48,10 @@ func newHeaderPanelStyles() headerPanelStyles { return headerPanelStyles{ panel: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), label: ui.DimStyle(), - value: lipgloss.NewStyle().Foreground(t.Text), + value: ui.TextStyle(), accent: ui.HighlightStyle(), dim: ui.MutedStyle(), - separator: lipgloss.NewStyle().Foreground(t.Border), + separator: ui.BorderStyle(), } } diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 420592ad..57cbd3cb 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -17,12 +17,11 @@ type helpViewStyles struct { } func newHelpViewStyles() helpViewStyles { - t := ui.Current() return helpViewStyles{ title: ui.TitleStyle(), section: ui.SectionStyle().MarginTop(1), key: ui.SuccessStyle().Width(15), - desc: lipgloss.NewStyle().Foreground(t.Text), + desc: ui.TextStyle(), } } diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 34e38a7d..e5f4bc2d 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -64,11 +64,10 @@ type logViewStyles struct { } func newLogViewStyles() logViewStyles { - t := ui.Current() return logViewStyles{ header: ui.TitleStyle(), - timestamp: lipgloss.NewStyle().Foreground(t.Secondary), - message: lipgloss.NewStyle().Foreground(t.Text), + timestamp: ui.SecondaryStyle(), + message: ui.TextStyle(), paused: ui.BoldWarningStyle(), error: ui.DangerStyle(), dim: ui.DimStyle(), diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index bbcaeb6f..30c44e29 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -72,7 +72,6 @@ type flatItem struct { } type serviceBrowserStyles struct { - title lipgloss.Style category lipgloss.Style cell lipgloss.Style cellSelected lipgloss.Style @@ -84,11 +83,7 @@ type serviceBrowserStyles struct { } func newServiceBrowserStyles() serviceBrowserStyles { - t := ui.Current() return serviceBrowserStyles{ - title: ui.TableHeaderStyle(). - Padding(0, 1). - MarginBottom(1), category: ui.DimStyle(). Bold(true). MarginTop(1). @@ -101,14 +96,11 @@ func newServiceBrowserStyles() serviceBrowserStyles { Width(cellWidth). Height(cellHeight). Padding(0, 1), - serviceName: lipgloss.NewStyle(). - Bold(true). - Foreground(t.Text), + serviceName: ui.TextStyle().Bold(true), serviceNameSe: ui.TitleStyle(), aliases: ui.DimStyle(), aliasesSel: ui.DimStyle(), - filterPrompt: lipgloss.NewStyle(). - Foreground(t.Primary), + filterPrompt: ui.PrimaryStyle(), } } From bf698760bec986656aeb52af6a1a535f3ab0a882 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 16:50:44 +0000 Subject: [PATCH 10/12] refactor: add InfoStyle/PendingStyle/TextBrightStyle, extend helpers to render/action (#88) --- internal/action/exec_with_header.go | 31 ++++++----------------------- internal/render/detail.go | 9 ++++----- internal/render/render.go | 11 +++++----- internal/ui/theme.go | 15 ++++++++++++++ 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/internal/action/exec_with_header.go b/internal/action/exec_with_header.go index 4fd88914..a0a696b9 100644 --- a/internal/action/exec_with_header.go +++ b/internal/action/exec_with_header.go @@ -8,7 +8,6 @@ import ( "os/exec" "strings" - "charm.land/lipgloss/v2" "golang.org/x/term" "github.com/clawscli/claws/internal/aws" @@ -148,8 +147,7 @@ func (e *ExecWithHeader) Run() error { _, _ = fmt.Fprint(stdout, header) // Print separator - separatorStyle := lipgloss.NewStyle().Foreground(ui.Current().TextDim) - _, _ = fmt.Fprintln(stdout, separatorStyle.Render(strings.Repeat("─", width))) + _, _ = fmt.Fprintln(stdout, ui.DimStyle().Render(strings.Repeat("─", width))) headerLines++ // Set scroll region to exclude header (1-indexed) @@ -209,37 +207,22 @@ func (e *ExecWithHeader) buildHeader(_ int) string { } accountID := config.Global().AccountID() - // Styles - t := ui.Current() - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary) - - labelStyle := lipgloss.NewStyle(). - Foreground(t.TextDim) - - valueStyle := lipgloss.NewStyle(). - Foreground(t.TextBright) - - regionStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(t.Secondary) + titleStyle := ui.TitleStyle() + labelStyle := ui.DimStyle() + valueStyle := ui.TextBrightStyle() + regionStyle := ui.SectionStyle() var lines []string - // Title line title := fmt.Sprintf("%s/%s", e.Service, e.ResType) lines = append(lines, titleStyle.Render(title)) - // Resource info resourceLine := labelStyle.Render("Resource: ") + valueStyle.Render(e.Resource.GetName()) if id := e.Resource.GetID(); id != e.Resource.GetName() { resourceLine += labelStyle.Render(" (") + valueStyle.Render(id) + labelStyle.Render(")") } lines = append(lines, resourceLine) - // Context line: Profile, Region, Account contextParts := []string{ labelStyle.Render("Profile: ") + valueStyle.Render(profileDisplay), } @@ -251,9 +234,7 @@ func (e *ExecWithHeader) buildHeader(_ int) string { } lines = append(lines, strings.Join(contextParts, " ")) - // Hint line - hintStyle := lipgloss.NewStyle().Foreground(ui.Current().TextDim).Italic(true) - lines = append(lines, hintStyle.Render("Press Ctrl+D or type 'exit' to return to claws")) + lines = append(lines, ui.DimStyle().Italic(true).Render("Press Ctrl+D or type 'exit' to return to claws")) return strings.Join(lines, "\n") } diff --git a/internal/render/detail.go b/internal/render/detail.go index 732fd230..cdae4b02 100644 --- a/internal/render/detail.go +++ b/internal/render/detail.go @@ -43,14 +43,13 @@ func DefaultDetailStyles() DetailStyles { if cachedDetailStyles != nil { return *cachedDetailStyles } - t := ui.Current() styles := DetailStyles{ Title: ui.TitleStyle(), Section: ui.SectionStyle().MarginTop(1), - Label: lipgloss.NewStyle().Foreground(t.TextDim).Width(32), - Value: lipgloss.NewStyle().Foreground(t.Text), - Dim: lipgloss.NewStyle().Foreground(t.TextDim), - Success: lipgloss.NewStyle().Foreground(t.Success), + Label: ui.DimStyle().Width(32), + Value: ui.TextStyle(), + Dim: ui.DimStyle(), + Success: ui.SuccessStyle(), } cachedDetailStyles = &styles return styles diff --git a/internal/render/render.go b/internal/render/render.go index 8d02823a..68a44f05 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -128,18 +128,17 @@ type Colorer func(value string) lipgloss.Style // StateColorer returns a colorer for common state values func StateColorer() Colorer { return func(value string) lipgloss.Style { - t := ui.Current() switch value { case "running", "available", "active", "healthy": - return lipgloss.NewStyle().Foreground(t.Success) + return ui.SuccessStyle() case "in-use", "attached": - return lipgloss.NewStyle().Foreground(t.Info) + return ui.InfoStyle() case "stopped", "stopping", "deleting": - return lipgloss.NewStyle().Foreground(t.Warning) + return ui.WarningStyle() case "terminated", "failed", "error", "unhealthy", "deleted": - return lipgloss.NewStyle().Foreground(t.Danger) + return ui.DangerStyle() case "pending", "starting", "creating": - return lipgloss.NewStyle().Foreground(t.Pending) + return ui.PendingStyle() default: return lipgloss.NewStyle() } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index eb244ca4..3be846aa 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -158,6 +158,11 @@ func TextStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Text) } +// TextBrightStyle returns a style for emphasized text +func TextBrightStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.TextBright) +} + // SecondaryStyle returns a style for secondary-colored text func SecondaryStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Secondary) @@ -173,6 +178,16 @@ func PrimaryStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Primary) } +// InfoStyle returns a style for info states +func InfoStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Info) +} + +// PendingStyle returns a style for pending states +func PendingStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Pending) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot From b0d13bb27fb24e09dbe53d72e811263a986089db Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 17:07:25 +0000 Subject: [PATCH 11/12] refactor: remove render style wrappers, add BoxStyle/InputStyle, consolidate truncate (#88) --- custom/autoscaling/groups/render.go | 5 +- custom/cloudformation/events/render.go | 9 +- custom/cloudformation/resources/render.go | 15 +- custom/cloudformation/stacks/render.go | 15 +- custom/cloudfront/distributions/render.go | 5 +- custom/codebuild/projects/render.go | 5 +- custom/ecs/services/render.go | 3 +- custom/elasticache/clusters/render.go | 9 +- custom/secretsmanager/secrets/render.go | 5 +- custom/sns/topics/render.go | 11 +- custom/stepfunctions/state-machines/render.go | 3 +- custom/vpc/subnets/render.go | 7 +- custom/vpc/vpcs/render.go | 5 +- internal/app/app.go | 6 +- internal/render/render.go | 20 --- internal/render/render_test.go | 11 +- internal/ui/theme.go | 14 ++ internal/ui/theme_test.go | 160 ++++++++++++++++++ internal/view/action_menu.go | 6 +- internal/view/dashboard_view_panels.go | 10 +- internal/view/diff_view.go | 27 +-- internal/view/header_panel.go | 13 +- internal/view/strings.go | 29 ++++ internal/view/view_test.go | 8 +- 24 files changed, 283 insertions(+), 118 deletions(-) create mode 100644 internal/view/strings.go diff --git a/custom/autoscaling/groups/render.go b/custom/autoscaling/groups/render.go index adfabcf1..89b889dd 100644 --- a/custom/autoscaling/groups/render.go +++ b/custom/autoscaling/groups/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // AutoScalingGroupRenderer renders Auto Scaling Groups @@ -192,9 +193,9 @@ func (r *AutoScalingGroupRenderer) RenderDetail(resource dao.Resource) string { } if proc.ProcessName != nil { if reason != "" { - d.FieldStyled(*proc.ProcessName, reason, render.WarningStyle()) + d.FieldStyled(*proc.ProcessName, reason, ui.WarningStyle()) } else { - d.FieldStyled(*proc.ProcessName, "Suspended", render.WarningStyle()) + d.FieldStyled(*proc.ProcessName, "Suspended", ui.WarningStyle()) } } } diff --git a/custom/cloudformation/events/render.go b/custom/cloudformation/events/render.go index fc0e612d..379b813e 100644 --- a/custom/cloudformation/events/render.go +++ b/custom/cloudformation/events/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // EventRenderer renders CloudFormation stack events @@ -140,13 +141,13 @@ func (r *EventRenderer) RenderSummary(resource dao.Resource) []render.SummaryFie func cfnResourceStatusColorer(status string) render.Style { switch { case strings.HasSuffix(status, "_COMPLETE") && !strings.Contains(status, "ROLLBACK") && !strings.Contains(status, "DELETE"): - return render.SuccessStyle() + return ui.SuccessStyle() case strings.Contains(status, "IN_PROGRESS"): - return render.WarningStyle() + return ui.WarningStyle() case strings.Contains(status, "FAILED") || strings.Contains(status, "ROLLBACK"): - return render.DangerStyle() + return ui.DangerStyle() case strings.Contains(status, "DELETE_COMPLETE") || strings.Contains(status, "SKIPPED"): - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } diff --git a/custom/cloudformation/resources/render.go b/custom/cloudformation/resources/render.go index 89329cc3..3874efad 100644 --- a/custom/cloudformation/resources/render.go +++ b/custom/cloudformation/resources/render.go @@ -8,6 +8,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/registry" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure ResourceRenderer implements render.Navigator @@ -148,13 +149,13 @@ func (r *ResourceRenderer) RenderSummary(resource dao.Resource) []render.Summary func cfnResourceStatusColorer(status string) render.Style { switch { case strings.HasSuffix(status, "_COMPLETE") && !strings.Contains(status, "ROLLBACK") && !strings.Contains(status, "DELETE"): - return render.SuccessStyle() + return ui.SuccessStyle() case strings.Contains(status, "IN_PROGRESS"): - return render.WarningStyle() + return ui.WarningStyle() case strings.Contains(status, "FAILED") || strings.Contains(status, "ROLLBACK"): - return render.DangerStyle() + return ui.DangerStyle() case strings.Contains(status, "DELETE_COMPLETE") || strings.Contains(status, "SKIPPED"): - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } @@ -164,11 +165,11 @@ func cfnResourceStatusColorer(status string) render.Style { func driftColorer(status string) render.Style { switch status { case "IN_SYNC": - return render.SuccessStyle() + return ui.SuccessStyle() case "MODIFIED", "DELETED": - return render.DangerStyle() + return ui.DangerStyle() case "NOT_CHECKED": - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } diff --git a/custom/cloudformation/stacks/render.go b/custom/cloudformation/stacks/render.go index 3984ee0e..2305346d 100644 --- a/custom/cloudformation/stacks/render.go +++ b/custom/cloudformation/stacks/render.go @@ -7,6 +7,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure StackRenderer implements render.Navigator @@ -244,13 +245,13 @@ func (r *StackRenderer) RenderSummary(resource dao.Resource) []render.SummaryFie func cfnStateColorer(status string) render.Style { switch { case strings.HasSuffix(status, "_COMPLETE") && !strings.Contains(status, "ROLLBACK") && !strings.Contains(status, "DELETE"): - return render.SuccessStyle() + return ui.SuccessStyle() case strings.Contains(status, "IN_PROGRESS"): - return render.WarningStyle() + return ui.WarningStyle() case strings.Contains(status, "FAILED") || strings.Contains(status, "ROLLBACK"): - return render.DangerStyle() + return ui.DangerStyle() case strings.Contains(status, "DELETE_COMPLETE"): - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } @@ -260,11 +261,11 @@ func cfnStateColorer(status string) render.Style { func driftColorer(status string) render.Style { switch status { case "IN_SYNC": - return render.SuccessStyle() + return ui.SuccessStyle() case "DRIFTED": - return render.DangerStyle() + return ui.DangerStyle() case "NOT_CHECKED": - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } diff --git a/custom/cloudfront/distributions/render.go b/custom/cloudfront/distributions/render.go index cb941c51..d33b498e 100644 --- a/custom/cloudfront/distributions/render.go +++ b/custom/cloudfront/distributions/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // DistributionRenderer renders CloudFront distributions @@ -165,7 +166,7 @@ func (r *DistributionRenderer) RenderDetail(resource dao.Resource) string { d.Field("Price Class", dist.PriceClass()) d.Field("HTTP Version", dist.HttpVersion()) if dist.IsIPV6Enabled { - d.FieldStyled("IPv6", "Enabled", render.SuccessStyle()) + d.FieldStyled("IPv6", "Enabled", ui.SuccessStyle()) } else { d.Field("IPv6", "Disabled") } @@ -173,7 +174,7 @@ func (r *DistributionRenderer) RenderDetail(resource dao.Resource) string { // Access Logging if dist.Logging != nil && dist.Logging.Enabled != nil && *dist.Logging.Enabled { d.Section("Access Logging") - d.FieldStyled("Status", "Enabled", render.SuccessStyle()) + d.FieldStyled("Status", "Enabled", ui.SuccessStyle()) if dist.Logging.Bucket != nil { d.Field("Bucket", *dist.Logging.Bucket) } diff --git a/custom/codebuild/projects/render.go b/custom/codebuild/projects/render.go index 73f0b3f5..59d6bb94 100644 --- a/custom/codebuild/projects/render.go +++ b/custom/codebuild/projects/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // ProjectRenderer renders CodeBuild projects @@ -164,13 +165,13 @@ func (r *ProjectRenderer) RenderDetail(resource dao.Resource) string { logs := project.Project.LogsConfig d.Section("Logging") if logs.CloudWatchLogs != nil && logs.CloudWatchLogs.Status == "ENABLED" { - d.FieldStyled("CloudWatch Logs", "Enabled", render.SuccessStyle()) + d.FieldStyled("CloudWatch Logs", "Enabled", ui.SuccessStyle()) if logs.CloudWatchLogs.GroupName != nil { d.Field(" Log Group", *logs.CloudWatchLogs.GroupName) } } if logs.S3Logs != nil && logs.S3Logs.Status == "ENABLED" { - d.FieldStyled("S3 Logs", "Enabled", render.SuccessStyle()) + d.FieldStyled("S3 Logs", "Enabled", ui.SuccessStyle()) if logs.S3Logs.Location != nil { d.Field(" Location", *logs.S3Logs.Location) } diff --git a/custom/ecs/services/render.go b/custom/ecs/services/render.go index 9056c1e1..607d2d9f 100644 --- a/custom/ecs/services/render.go +++ b/custom/ecs/services/render.go @@ -7,6 +7,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // ServiceRenderer renders ECS services @@ -155,7 +156,7 @@ func (r *ServiceRenderer) RenderDetail(resource dao.Resource) string { if dc.DeploymentCircuitBreaker != nil { cb := dc.DeploymentCircuitBreaker if cb.Enable { - d.FieldStyled("Circuit Breaker", "Enabled", render.SuccessStyle()) + d.FieldStyled("Circuit Breaker", "Enabled", ui.SuccessStyle()) if cb.Rollback { d.Field("Rollback on Failure", "Enabled") } diff --git a/custom/elasticache/clusters/render.go b/custom/elasticache/clusters/render.go index c41c5698..bded6a6d 100644 --- a/custom/elasticache/clusters/render.go +++ b/custom/elasticache/clusters/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // ClusterRenderer renders ElastiCache clusters @@ -151,7 +152,7 @@ func (r *ClusterRenderer) RenderDetail(resource dao.Resource) string { nodeId = *node.CacheNodeId } if status == "available" { - d.FieldStyled(nodeId, status, render.SuccessStyle()) + d.FieldStyled(nodeId, status, ui.SuccessStyle()) } else { d.Field(nodeId, status) } @@ -161,17 +162,17 @@ func (r *ClusterRenderer) RenderDetail(resource dao.Resource) string { // Security d.Section("Security") if cluster.TransitEncryptionEnabled() { - d.FieldStyled("Transit Encryption", "Enabled", render.SuccessStyle()) + d.FieldStyled("Transit Encryption", "Enabled", ui.SuccessStyle()) } else { d.Field("Transit Encryption", "Disabled") } if cluster.AtRestEncryptionEnabled() { - d.FieldStyled("At-Rest Encryption", "Enabled", render.SuccessStyle()) + d.FieldStyled("At-Rest Encryption", "Enabled", ui.SuccessStyle()) } else { d.Field("At-Rest Encryption", "Disabled") } if cluster.Item.AuthTokenEnabled != nil && *cluster.Item.AuthTokenEnabled { - d.FieldStyled("AUTH Token", "Enabled", render.SuccessStyle()) + d.FieldStyled("AUTH Token", "Enabled", ui.SuccessStyle()) } d.Field("Auto Minor Version Upgrade", formatBool(cluster.AutoMinorVersionUpgrade())) diff --git a/custom/secretsmanager/secrets/render.go b/custom/secretsmanager/secrets/render.go index eafa3a21..714b8ce7 100644 --- a/custom/secretsmanager/secrets/render.go +++ b/custom/secretsmanager/secrets/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // SecretRenderer renders Secrets Manager secrets @@ -86,7 +87,7 @@ func (r *SecretRenderer) RenderDetail(resource dao.Resource) string { // Deletion Status if secret.DeletedDate != nil { d.Section("Deletion") - d.FieldStyled("Status", "SCHEDULED FOR DELETION", render.DangerStyle()) + d.FieldStyled("Status", "SCHEDULED FOR DELETION", ui.DangerStyle()) d.Field("Deletion Date", *secret.DeletedDate) } @@ -101,7 +102,7 @@ func (r *SecretRenderer) RenderDetail(resource dao.Resource) string { // Rotation d.Section("Rotation") if secret.RotationEnabled { - d.FieldStyled("Rotation", "Enabled", render.SuccessStyle()) + d.FieldStyled("Rotation", "Enabled", ui.SuccessStyle()) if secret.RotationLambdaARN != "" { d.Field("Lambda ARN", secret.RotationLambdaARN) } diff --git a/custom/sns/topics/render.go b/custom/sns/topics/render.go index b8f983ee..bd990903 100644 --- a/custom/sns/topics/render.go +++ b/custom/sns/topics/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure TopicRenderer implements render.Navigator @@ -149,23 +150,23 @@ func (r *TopicRenderer) RenderDetail(resource dao.Resource) string { } if role, ok := tr.Attrs["HTTPSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("HTTP", "Enabled", render.SuccessStyle()) + d.FieldStyled("HTTP", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["LambdaSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("Lambda", "Enabled", render.SuccessStyle()) + d.FieldStyled("Lambda", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["SQSSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("SQS", "Enabled", render.SuccessStyle()) + d.FieldStyled("SQS", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["FirehoseSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("Firehose", "Enabled", render.SuccessStyle()) + d.FieldStyled("Firehose", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["ApplicationSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("Application", "Enabled", render.SuccessStyle()) + d.FieldStyled("Application", "Enabled", ui.SuccessStyle()) } // Encryption diff --git a/custom/stepfunctions/state-machines/render.go b/custom/stepfunctions/state-machines/render.go index 05893ab4..4ac67efe 100644 --- a/custom/stepfunctions/state-machines/render.go +++ b/custom/stepfunctions/state-machines/render.go @@ -7,6 +7,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure StateMachineRenderer implements render.Navigator @@ -131,7 +132,7 @@ func (r *StateMachineRenderer) RenderDetail(resource dao.Resource) string { tc := sr.Detail.TracingConfiguration d.Section("Tracing (X-Ray)") if tc.Enabled { - d.FieldStyled("X-Ray Tracing", "Enabled", render.SuccessStyle()) + d.FieldStyled("X-Ray Tracing", "Enabled", ui.SuccessStyle()) } else { d.Field("X-Ray Tracing", "Disabled") } diff --git a/custom/vpc/subnets/render.go b/custom/vpc/subnets/render.go index 2ca10bac..9b1e16b7 100644 --- a/custom/vpc/subnets/render.go +++ b/custom/vpc/subnets/render.go @@ -3,11 +3,10 @@ package subnets import ( "fmt" - "charm.land/lipgloss/v2" - appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure SubnetRenderer implements render.Navigator @@ -126,9 +125,9 @@ func (r *SubnetRenderer) RenderDetail(resource dao.Resource) string { // Public/Private subnet indicator if sr.IsPublic() { - d.FieldStyled("Subnet Type", "Public", lipgloss.NewStyle().Foreground(lipgloss.Color("42"))) + d.FieldStyled("Subnet Type", "Public", ui.SuccessStyle()) } else { - d.FieldStyled("Subnet Type", "Private", lipgloss.NewStyle().Foreground(lipgloss.Color("33"))) + d.FieldStyled("Subnet Type", "Private", ui.SecondaryStyle()) } if sr.Item.AvailabilityZoneId != nil { diff --git a/custom/vpc/vpcs/render.go b/custom/vpc/vpcs/render.go index 6df16214..f9e3e5ba 100644 --- a/custom/vpc/vpcs/render.go +++ b/custom/vpc/vpcs/render.go @@ -6,6 +6,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure VPCRenderer implements render.Navigator @@ -117,12 +118,12 @@ func (r *VPCRenderer) RenderDetail(resource dao.Resource) string { // DNS Settings d.Section("DNS Settings") if vr.EnableDnsSupport { - d.FieldStyled("DNS Resolution", "Enabled", render.SuccessStyle()) + d.FieldStyled("DNS Resolution", "Enabled", ui.SuccessStyle()) } else { d.Field("DNS Resolution", "Disabled") } if vr.EnableDnsHostnames { - d.FieldStyled("DNS Hostnames", "Enabled", render.SuccessStyle()) + d.FieldStyled("DNS Hostnames", "Enabled", ui.SuccessStyle()) } else { d.Field("DNS Hostnames", "Disabled") } diff --git a/internal/app/app.go b/internal/app/app.go index cc89ea1e..9b5a2e7b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -51,9 +51,9 @@ func newAppStyles(width int) appStyles { status: ui.TableHeaderStyle().Padding(0, 1).Width(width), readOnly: lipgloss.NewStyle().Background(t.Warning).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 1), warningTitle: ui.BoldPendingStyle().MarginBottom(1), - warningItem: lipgloss.NewStyle().Foreground(t.Warning), - warningDim: lipgloss.NewStyle().Foreground(t.TextDim).MarginTop(1), - warningBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Pending).Padding(1, 2), + warningItem: ui.WarningStyle(), + warningDim: ui.DimStyle().MarginTop(1), + warningBox: ui.BoxStyle().BorderForeground(t.Pending).Padding(1, 2), } } diff --git a/internal/render/render.go b/internal/render/render.go index 68a44f05..2699536f 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -202,26 +202,6 @@ func FormatDuration(d time.Duration) string { // Style is an alias for lipgloss.Style for convenience type Style = lipgloss.Style -// SuccessStyle returns a green style for success states -func SuccessStyle() lipgloss.Style { - return ui.SuccessStyle() -} - -// WarningStyle returns a yellow style for warning states -func WarningStyle() lipgloss.Style { - return ui.WarningStyle() -} - -// DangerStyle returns a red style for danger/error states -func DangerStyle() lipgloss.Style { - return ui.DangerStyle() -} - -// DimStyle returns a dimmed gray style -func DimStyle() lipgloss.Style { - return ui.DimStyle() -} - // DefaultStyle returns a default unstyled style func DefaultStyle() lipgloss.Style { return lipgloss.NewStyle() diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 1019ab6e..10efa83b 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/ui" ) func TestFormatAge(t *testing.T) { @@ -278,10 +279,10 @@ func TestTagsColumn(t *testing.T) { func TestStyleHelpers(t *testing.T) { // These just verify the functions don't panic - _ = SuccessStyle().Render("test") - _ = WarningStyle().Render("test") - _ = DangerStyle().Render("test") - _ = DimStyle().Render("test") + _ = ui.SuccessStyle().Render("test") + _ = ui.WarningStyle().Render("test") + _ = ui.DangerStyle().Render("test") + _ = ui.DimStyle().Render("test") _ = DefaultStyle().Render("test") } @@ -393,7 +394,7 @@ func TestDetailBuilder_Section(t *testing.T) { func TestDetailBuilder_FieldStyled(t *testing.T) { d := NewDetailBuilder() - d.FieldStyled("Status", "running", SuccessStyle()) + d.FieldStyled("Status", "running", ui.SuccessStyle()) result := d.String() if !strings.Contains(result, "Status") { t.Errorf("FieldStyled() should contain label, got: %s", result) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 3be846aa..4cab83e9 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -188,6 +188,20 @@ func PendingStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Pending) } +func BoxStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(current.Border). + Padding(0, 1) +} + +func InputStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(current.Border). + Padding(0, 1) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index b36210f8..0849bdb9 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -117,6 +117,166 @@ func TestNewSpinner(t *testing.T) { } } +func TestTitleStyle(t *testing.T) { + style := TitleStyle() + rendered := style.Render("title") + if rendered == "" { + t.Error("TitleStyle().Render() should produce output") + } +} + +func TestSelectedStyle(t *testing.T) { + style := SelectedStyle() + rendered := style.Render("selected") + if rendered == "" { + t.Error("SelectedStyle().Render() should produce output") + } +} + +func TestTableHeaderStyle(t *testing.T) { + style := TableHeaderStyle() + rendered := style.Render("header") + if rendered == "" { + t.Error("TableHeaderStyle().Render() should produce output") + } +} + +func TestSectionStyle(t *testing.T) { + style := SectionStyle() + rendered := style.Render("section") + if rendered == "" { + t.Error("SectionStyle().Render() should produce output") + } +} + +func TestHighlightStyle(t *testing.T) { + style := HighlightStyle() + rendered := style.Render("highlight") + if rendered == "" { + t.Error("HighlightStyle().Render() should produce output") + } +} + +func TestBoldSuccessStyle(t *testing.T) { + style := BoldSuccessStyle() + rendered := style.Render("bold success") + if rendered == "" { + t.Error("BoldSuccessStyle().Render() should produce output") + } +} + +func TestBoldDangerStyle(t *testing.T) { + style := BoldDangerStyle() + rendered := style.Render("bold danger") + if rendered == "" { + t.Error("BoldDangerStyle().Render() should produce output") + } +} + +func TestBoldWarningStyle(t *testing.T) { + style := BoldWarningStyle() + rendered := style.Render("bold warning") + if rendered == "" { + t.Error("BoldWarningStyle().Render() should produce output") + } +} + +func TestBoldPendingStyle(t *testing.T) { + style := BoldPendingStyle() + rendered := style.Render("bold pending") + if rendered == "" { + t.Error("BoldPendingStyle().Render() should produce output") + } +} + +func TestAccentStyle(t *testing.T) { + style := AccentStyle() + rendered := style.Render("accent") + if rendered == "" { + t.Error("AccentStyle().Render() should produce output") + } +} + +func TestMutedStyle(t *testing.T) { + style := MutedStyle() + rendered := style.Render("muted") + if rendered == "" { + t.Error("MutedStyle().Render() should produce output") + } +} + +func TestTextStyle(t *testing.T) { + style := TextStyle() + rendered := style.Render("text") + if rendered == "" { + t.Error("TextStyle().Render() should produce output") + } +} + +func TestTextBrightStyle(t *testing.T) { + style := TextBrightStyle() + rendered := style.Render("bright") + if rendered == "" { + t.Error("TextBrightStyle().Render() should produce output") + } +} + +func TestSecondaryStyle(t *testing.T) { + style := SecondaryStyle() + rendered := style.Render("secondary") + if rendered == "" { + t.Error("SecondaryStyle().Render() should produce output") + } +} + +func TestBorderStyle(t *testing.T) { + style := BorderStyle() + rendered := style.Render("border") + if rendered == "" { + t.Error("BorderStyle().Render() should produce output") + } +} + +func TestPrimaryStyle(t *testing.T) { + style := PrimaryStyle() + rendered := style.Render("primary") + if rendered == "" { + t.Error("PrimaryStyle().Render() should produce output") + } +} + +func TestInfoStyle(t *testing.T) { + style := InfoStyle() + rendered := style.Render("info") + if rendered == "" { + t.Error("InfoStyle().Render() should produce output") + } +} + +func TestPendingStyle(t *testing.T) { + style := PendingStyle() + rendered := style.Render("pending") + if rendered == "" { + t.Error("PendingStyle().Render() should produce output") + } +} + +func TestBoxStyle(t *testing.T) { + style := BoxStyle() + rendered := style.Render("box content") + if rendered == "" { + t.Error("BoxStyle().Render() should produce output") + } +} + +func TestInputStyle(t *testing.T) { + style := InputStyle() + rendered := style.Render("input content") + if rendered == "" { + t.Error("InputStyle().Render() should produce output") + } +} + func TestThemeFields(t *testing.T) { theme := DefaultTheme() diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index b499563f..2359aa4f 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -40,12 +40,12 @@ func newActionMenuStyles() actionMenuStyles { item: lipgloss.NewStyle().PaddingLeft(2), selected: ui.SelectedStyle().PaddingLeft(2), shortcut: ui.SecondaryStyle(), - box: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1).MarginTop(1), - dangerBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Danger).Padding(0, 1).MarginTop(1), + box: ui.BoxStyle().MarginTop(1), + dangerBox: ui.BoxStyle().BorderForeground(t.Danger).MarginTop(1), yes: ui.BoldSuccessStyle(), no: ui.BoldDangerStyle(), bold: lipgloss.NewStyle().Bold(true), - input: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(t.Border).Padding(0, 1), + input: ui.InputStyle(), } } diff --git a/internal/view/dashboard_view_panels.go b/internal/view/dashboard_view_panels.go index 2cb1ca22..118862d2 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -97,7 +97,7 @@ func (d *DashboardView) renderCostContent(contentWidth, contentHeight int, t *ui for i := range showCount { c := d.costTop[i] bar := renderBar(c.cost, maxCost, barWidth, t) - name := truncateValue(c.service, nameWidth) + name := TruncateString(c.service, nameWidth) line := fmt.Sprintf("%-*s %s %8.0f", nameWidth, name, bar, c.cost) if i == focusRow { line = s.highlight.Render(line) @@ -133,7 +133,7 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR lines = append(lines, s.danger.Render(fmt.Sprintf("Alarms: %d in ALARM", alarmCount))) maxShow := min(alarmCount, contentHeight-3) for i := range maxShow { - line := " " + s.danger.Render("• ") + truncateValue(d.alarms[i].name, contentWidth-bulletIndentWidth) + line := " " + s.danger.Render("• ") + TruncateString(d.alarms[i].name, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } @@ -153,7 +153,7 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR maxShow := min(len(d.healthItems), remaining) for i := range maxShow { h := d.healthItems[i] - line := " " + s.warning.Render("• ") + truncateValue(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) + line := " " + s.warning.Render("• ") + TruncateString(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) if alarmCount+i == focusRow { line = s.highlight.Render(line) } @@ -196,7 +196,7 @@ func (d *DashboardView) renderSecurityContent(contentWidth, contentHeight int, f if item.severity == "CRITICAL" { style = s.danger } - line := " " + style.Render("• ") + truncateValue(item.title, contentWidth-bulletIndentWidth) + line := " " + style.Render("• ") + TruncateString(item.title, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } @@ -243,7 +243,7 @@ func (d *DashboardView) renderOptimizationContent(contentWidth, contentHeight in if item.status == "error" { style = s.danger } - line := " " + style.Render("• ") + truncateValue(item.name, contentWidth-bulletIndentWidth) + line := " " + style.Render("• ") + TruncateString(item.name, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 3b8a83dc..4a5ed88a 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -6,7 +6,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" @@ -132,8 +131,8 @@ func (d *DiffView) renderSideBySide() string { colWidth := (d.width - 3) / 2 // Column headers - leftHeader := truncateOrPad("◀ "+d.left.GetName(), colWidth) - rightHeader := truncateOrPad(d.right.GetName()+" ▶", colWidth) + leftHeader := TruncateOrPadString("◀ "+d.left.GetName(), colWidth) + rightHeader := TruncateOrPadString(d.right.GetName()+" ▶", colWidth) out.WriteString(s.header.Render(leftHeader)) out.WriteString(s.separator.Render(" │ ")) out.WriteString(s.header.Render(rightHeader)) @@ -157,29 +156,11 @@ func (d *DiffView) renderSideBySide() string { rightLine = rightLines[i] } - out.WriteString(truncateOrPad(leftLine, colWidth)) + out.WriteString(TruncateOrPadString(leftLine, colWidth)) out.WriteString(s.separator.Render(" │ ")) - out.WriteString(truncateOrPad(rightLine, colWidth)) + out.WriteString(TruncateOrPadString(rightLine, colWidth)) out.WriteString("\n") } return out.String() } - -// truncateOrPad ensures a string is exactly the specified width -func truncateOrPad(s string, width int) string { - if width <= 0 { - return "" - } - - // Use lipgloss.Width for proper ANSI-aware width calculation - plainLen := lipgloss.Width(s) - - if plainLen > width { - // Use ansi.Truncate for proper ANSI-aware truncation - return ansi.Truncate(s, width, "…") - } - - // Pad with spaces - return s + strings.Repeat(" ", width-plainLen) -} diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index 54fd726f..50682a0d 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -6,7 +6,6 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/mattn/go-runewidth" "github.com/clawscli/claws/internal/config" "github.com/clawscli/claws/internal/registry" @@ -22,16 +21,6 @@ const ( maxFieldValueWidth = 30 ) -// truncateValue truncates a string to maxWidth, adding "…" if truncated -func truncateValue(s string, maxWidth int) string { - if runewidth.StringWidth(s) <= maxWidth { - return s - } - // Truncate to fit maxWidth-1 to leave room for ellipsis - truncated := runewidth.Truncate(s, maxWidth-1, "") - return truncated + "…" -} - // HeaderPanel renders the fixed header panel at the top of resource views // headerPanelStyles holds cached lipgloss styles for performance type headerPanelStyles struct { @@ -191,7 +180,7 @@ func (h *HeaderPanel) Render(service, resourceType string, summaryFields []rende } // Truncate long values to prevent line wrapping - truncatedValue := truncateValue(field.Value, maxFieldValueWidth) + truncatedValue := TruncateString(field.Value, maxFieldValueWidth) // Format field with appropriate styling var styledValue string diff --git a/internal/view/strings.go b/internal/view/strings.go new file mode 100644 index 00000000..c8f06013 --- /dev/null +++ b/internal/view/strings.go @@ -0,0 +1,29 @@ +package view + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func TruncateString(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(s) <= maxWidth { + return s + } + return ansi.Truncate(s, maxWidth, "…") +} + +func TruncateOrPadString(s string, width int) string { + if width <= 0 { + return "" + } + w := lipgloss.Width(s) + if w > width { + return ansi.Truncate(s, width, "…") + } + return s + strings.Repeat(" ", width-w) +} diff --git a/internal/view/view_test.go b/internal/view/view_test.go index bd1e9dd3..29d679fa 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -61,7 +61,7 @@ func TestIsEscKey(t *testing.T) { } } -// truncateOrPad tests +// TruncateOrPadString tests func TestTruncateOrPad(t *testing.T) { tests := []struct { @@ -120,16 +120,16 @@ func TestTruncateOrPad(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := truncateOrPad(tt.input, tt.width) + got := TruncateOrPadString(tt.input, tt.width) // Check visual width (rune count for plain text with ellipsis) gotLen := len([]rune(got)) if tt.wantLen > 0 && gotLen != tt.wantLen { - t.Errorf("truncateOrPad(%q, %d) rune len = %d, want %d (got=%q)", tt.input, tt.width, gotLen, tt.wantLen, got) + t.Errorf("TruncateOrPadString(%q, %d) rune len = %d, want %d (got=%q)", tt.input, tt.width, gotLen, tt.wantLen, got) } if tt.wantEnd != "" && !strings.HasSuffix(got, tt.wantEnd) { - t.Errorf("truncateOrPad(%q, %d) = %q, want suffix %q", tt.input, tt.width, got, tt.wantEnd) + t.Errorf("TruncateOrPadString(%q, %d) = %q, want suffix %q", tt.input, tt.width, got, tt.wantEnd) } }) } From 1bfd0531a702e4016db2bc2e2491dc4feaffd6f0 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 17:17:04 +0000 Subject: [PATCH 12/12] refactor: add InputFieldStyle, cache tag_search_view styles (#88) --- internal/ui/theme.go | 8 ++++++ internal/ui/theme_test.go | 8 ++++++ internal/view/command_input.go | 3 +-- internal/view/diff_view.go | 3 +-- internal/view/resource_browser.go | 7 +++--- internal/view/tag_search_view.go | 41 +++++++++++++++++-------------- 6 files changed, 44 insertions(+), 26 deletions(-) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 4cab83e9..d4ab2ee2 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -202,6 +202,14 @@ func InputStyle() lipgloss.Style { Padding(0, 1) } +// InputFieldStyle returns a style for input fields (filter, command input) +func InputFieldStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Background(current.Background). + Foreground(current.Text). + Padding(0, 1) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index 0849bdb9..c8f1fc41 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -277,6 +277,14 @@ func TestInputStyle(t *testing.T) { } } +func TestInputFieldStyle(t *testing.T) { + style := InputFieldStyle() + rendered := style.Render("filter text") + if rendered == "" { + t.Error("InputFieldStyle().Render() should produce output") + } +} + func TestThemeFields(t *testing.T) { theme := DefaultTheme() diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 663e0ba2..8a6ea5e4 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -26,9 +26,8 @@ type commandInputStyles struct { } func newCommandInputStyles() commandInputStyles { - t := ui.Current() return commandInputStyles{ - input: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), + input: ui.InputFieldStyle(), suggestion: ui.DimStyle(), highlight: ui.HighlightStyle(), } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 4a5ed88a..80c8514a 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -31,11 +31,10 @@ type diffViewStyles struct { } func newDiffViewStyles() diffViewStyles { - t := ui.Current() return diffViewStyles{ title: ui.TitleStyle(), header: ui.SectionStyle(), - separator: lipgloss.NewStyle().Foreground(t.TableBorder), + separator: ui.MutedStyle(), } } diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index bac5c4b6..cc2d981a 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -38,12 +38,11 @@ type resourceBrowserStyles struct { } func newResourceBrowserStyles() resourceBrowserStyles { - t := ui.Current() return resourceBrowserStyles{ count: ui.DimStyle(), - filterBg: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), - filterActive: lipgloss.NewStyle().Foreground(t.Accent).Italic(true), - tabSingle: lipgloss.NewStyle().Foreground(t.Primary), + filterBg: ui.InputFieldStyle(), + filterActive: ui.AccentStyle().Italic(true), + tabSingle: ui.PrimaryStyle(), tabActive: ui.SelectedStyle().Padding(0, 1), tabInactive: ui.DimStyle().Padding(0, 1), } diff --git a/internal/view/tag_search_view.go b/internal/view/tag_search_view.go index 2eb0acac..f50beafc 100644 --- a/internal/view/tag_search_view.go +++ b/internal/view/tag_search_view.go @@ -33,10 +33,27 @@ type taggedARN struct { RawARN string } +type tagSearchViewStyles struct { + header lipgloss.Style + status lipgloss.Style + filterWrap lipgloss.Style + filterActive lipgloss.Style +} + +func newTagSearchViewStyles() tagSearchViewStyles { + return tagSearchViewStyles{ + header: ui.TableHeaderStyle().Padding(0, 1), + status: ui.DimStyle().Padding(0, 1), + filterWrap: lipgloss.NewStyle().Padding(0, 1), + filterActive: ui.AccentStyle().Italic(true), + } +} + type TagSearchView struct { ctx context.Context registry *registry.Registry tagFilter string + styles tagSearchViewStyles table table.Model resources []taggedARN @@ -67,6 +84,7 @@ func NewTagSearchView(ctx context.Context, reg *registry.Registry, tagFilter str ctx: ctx, registry: reg, tagFilter: tagFilter, + styles: newTagSearchViewStyles(), loading: true, filterInput: ti, spinner: ui.NewSpinner(), @@ -556,18 +574,13 @@ func (v *TagSearchView) buildTable() { } func (v *TagSearchView) ViewString() string { - theme := ui.Current() + s := v.styles title := "Tag Search" if v.tagFilter != "" { title = fmt.Sprintf("Tag Search: %s", v.tagFilter) } - header := lipgloss.NewStyle(). - Foreground(theme.TableHeaderText). - Background(theme.TableHeader). - Padding(0, 1). - Width(v.width). - Render(title) + header := s.header.Width(v.width).Render(title) if v.loading { return header + "\n" + v.spinner.View() + " Searching..." @@ -592,21 +605,13 @@ func (v *TagSearchView) ViewString() string { statusLine += fmt.Sprintf(" [%d region errors]", len(v.partialErrors)) } - status := lipgloss.NewStyle(). - Foreground(theme.TextDim). - Padding(0, 1). - Render(statusLine) + status := s.status.Render(statusLine) filterView := "" if v.filterActive { - filterView = lipgloss.NewStyle(). - Padding(0, 1). - Render(v.filterInput.View()) + "\n" + filterView = s.filterWrap.Render(v.filterInput.View()) + "\n" } else if v.filterText != "" { - filterView = lipgloss.NewStyle(). - Foreground(theme.Accent). - Italic(true). - Render(fmt.Sprintf("filter: %s", v.filterText)) + "\n" + filterView = s.filterActive.Render(fmt.Sprintf("filter: %s", v.filterText)) + "\n" } if len(v.filtered) == 0 && len(v.resources) > 0 {