From 84acc266a35acdc3edb1474ecf6d155c9a58326c Mon Sep 17 00:00:00 2001 From: xvlet Date: Mon, 24 Aug 2026 18:02:01 +0900 Subject: [PATCH] feat: add payload copy, centralize UI theme, and extract UseCase interface - UI: Created theme.go to centralize lipgloss color definitions into an AppTheme struct. - UI: Replaced hardcoded hex colors with AppTheme references across all TUI views and components. - UI: Added c and y keybindings in the message detail view to copy the raw payload directly to the OS clipboard. - UI: Updated the footer hints to display the new copy shortcut. - UI: Implemented a success notification for clipboard actions and prevented these notifications from falsely triggering the disconnected status. - UI: Updated AppModel to depend on the UseCase interface instead of a concrete struct pointer, strictly adhering to Clean Architecture principles. - Usecase: Extracted UseCase interface to decouple the UI layer from concrete business logic implementations. - Usecase: Renamed ActiveMQUseCase struct to activeMQInteractor to encapsulate the implementation details. - Usecase: Fixed a potential resource leak in GetFullMessageBody by catching and bubbling up temporary queue deletion errors using named return variables. - Build: Resolved ST1005 golangci-lint warning by removing trailing punctuation from error strings. --- .gitignore | 1 + adapter/inbound/ui/form.go | 14 ++--- adapter/inbound/ui/theme.go | 33 +++++++++++ adapter/inbound/ui/tui.go | 14 ++--- adapter/inbound/ui/tui_time_delete.go | 34 +++++------ adapter/inbound/ui/tui_update.go | 11 ++++ adapter/inbound/ui/tui_view.go | 69 +++++++++++----------- usecase/usecase.go | 82 ++++++++++++++++++--------- 8 files changed, 168 insertions(+), 90 deletions(-) create mode 100644 adapter/inbound/ui/theme.go diff --git a/.gitignore b/.gitignore index 66c2281..e06f5a7 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,4 @@ build/ vendor/ temp/ +.agents/ diff --git a/adapter/inbound/ui/form.go b/adapter/inbound/ui/form.go index b66c41f..5cfcf44 100644 --- a/adapter/inbound/ui/form.go +++ b/adapter/inbound/ui/form.go @@ -13,14 +13,14 @@ var ( // Use standard bright ANSI colors to prevent dark gray rendering bugs // that occur with some pastel hex colors (like #cad3f5) in certain terminal environments. focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // Bright White - blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // Normal White - cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright Cyan - noStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Bright Black (Dark Gray) + blurredStyle = lipgloss.NewStyle().Foreground(AppTheme.Text) // Normal White + cursorStyle = lipgloss.NewStyle().Foreground(AppTheme.Highlight) // Bright Cyan + noStyle = lipgloss.NewStyle().Foreground(AppTheme.MutedText) // Bright Black (Dark Gray) - btnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 1) - focusedBtnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("14")).Bold(true).Padding(0, 1) + btnStyle = lipgloss.NewStyle().Foreground(AppTheme.Text).Padding(0, 1) + focusedBtnStyle = lipgloss.NewStyle().Foreground(AppTheme.Background).Background(AppTheme.Highlight).Bold(true).Padding(0, 1) - popupBorder = lipgloss.Color("14") // Bright Cyan + popupBorder = AppTheme.Highlight // Bright Cyan SubmitOptOk = "OK" SubmitOptCancel = "Cancel" @@ -157,7 +157,7 @@ func (m FormModel) View() string { buttons := lipgloss.JoinHorizontal(lipgloss.Top, okStyle.Render("[ OK ]"), " ", cancelStyle.Render("[ Cancel ]")) fmt.Fprintf(&b, "%s\n", buttons) - return lipgloss.NewStyle().Border(appBorder).BorderForeground(lipgloss.Color("#8aadf4")).Padding(1, 2).Render(b.String()) + return lipgloss.NewStyle().Border(appBorder).BorderForeground(AppTheme.Secondary).Padding(1, 2).Render(b.String()) } func (m FormModel) GetValues() []string { diff --git a/adapter/inbound/ui/theme.go b/adapter/inbound/ui/theme.go new file mode 100644 index 0000000..b75e39e --- /dev/null +++ b/adapter/inbound/ui/theme.go @@ -0,0 +1,33 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +type Theme struct { + Primary lipgloss.Color + Secondary lipgloss.Color + Success lipgloss.Color + Warning lipgloss.Color + Error lipgloss.Color + Info lipgloss.Color + Border lipgloss.Color + Text lipgloss.Color + MutedText lipgloss.Color + Highlight lipgloss.Color + Background lipgloss.Color + ActiveItem lipgloss.Color +} + +var AppTheme = Theme{ + Primary: lipgloss.Color("#c6a0f6"), // Pink/Purple (Headers) + Secondary: lipgloss.Color("#8aadf4"), // Blue (Focus) + Success: lipgloss.Color("#a6da95"), // Green (Connected, Success) + Warning: lipgloss.Color("208"), // Orange/Yellow (Warnings) + Error: lipgloss.Color("#ed8796"), // Red (Disconnected, Error) + Info: lipgloss.Color("#91d7e3"), // Cyan (Info, Memory, Temps) + Border: lipgloss.Color("#5b6078"), // Muted Blue/Gray (Borders) + Text: lipgloss.Color("#cad3f5"), // White/Light Gray (Normal text) + MutedText: lipgloss.Color("#8087a2"), // Darker Gray (Hints, Unfocused) + Highlight: lipgloss.Color("14"), // Bright Cyan (Search highlights) + Background: lipgloss.Color("#181926"), // Dark Base (Buttons, etc.) + ActiveItem: lipgloss.Color("#eed49f"), // Yellow (Active selection) +} diff --git a/adapter/inbound/ui/tui.go b/adapter/inbound/ui/tui.go index 2d7e6da..868a9e7 100644 --- a/adapter/inbound/ui/tui.go +++ b/adapter/inbound/ui/tui.go @@ -66,7 +66,7 @@ const ( ) type AppModel struct { - uc *usecase.ActiveMQUseCase + uc usecase.UseCase env string refreshInterval time.Duration viewStats bool @@ -120,7 +120,7 @@ func (m *AppModel) initViewport() { type tickMsg time.Time -func NewAppModel(uc *usecase.ActiveMQUseCase, interval time.Duration, host string, env string, readOnly bool) *AppModel { +func NewAppModel(uc usecase.UseCase, interval time.Duration, host string, env string, readOnly bool) *AppModel { // Original profile detection for conditional styling detectedProfile := lipgloss.ColorProfile() @@ -132,9 +132,9 @@ func NewAppModel(uc *usecase.ActiveMQUseCase, interval time.Duration, host strin // Initialize dynamic global styles based on profile isHighColor := (detectedProfile == termenv.ANSI256 || detectedProfile == termenv.TrueColor) if isHighColor { - titleStyle = lipgloss.NewStyle().MarginLeft(2).Bold(true).Foreground(lipgloss.Color("#c6a0f6")) + titleStyle = lipgloss.NewStyle().MarginLeft(2).Bold(true).Foreground(AppTheme.Primary) } else { - titleStyle = lipgloss.NewStyle().MarginLeft(2).Bold(true).Foreground(lipgloss.Color("#c6a0f6")) + titleStyle = lipgloss.NewStyle().MarginLeft(2).Bold(true).Foreground(AppTheme.Primary) } // 1. Queue Table @@ -180,17 +180,17 @@ func NewAppModel(uc *usecase.ActiveMQUseCase, interval time.Duration, host strin conTable := table.New(table.WithColumns(conCols), table.WithFocused(true)) s := table.DefaultStyles() - s.Header = s.Header.BorderStyle(appBorder).BorderForeground(lipgloss.Color("#5b6078")).BorderBottom(true).Bold(false) + s.Header = s.Header.BorderStyle(appBorder).BorderForeground(AppTheme.Border).BorderBottom(true).Bold(false) // Determine theme based on detected profile isHighColor = (detectedProfile == termenv.ANSI256 || detectedProfile == termenv.TrueColor) if isHighColor { // Original 256-color theme - s.Selected = s.Selected.Foreground(lipgloss.Color("#181926")).Background(lipgloss.Color("#8aadf4")).Bold(false) + s.Selected = s.Selected.Foreground(AppTheme.Background).Background(AppTheme.Secondary).Bold(false) } else { // Limited terminal: Use high-contrast Black on White theme (as requested) - s.Selected = s.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("15")).Bold(false) + s.Selected = s.Selected.Foreground(AppTheme.Background).Background(lipgloss.Color("15")).Bold(false) } qTable.SetStyles(s) diff --git a/adapter/inbound/ui/tui_time_delete.go b/adapter/inbound/ui/tui_time_delete.go index 5909410..4121d28 100644 --- a/adapter/inbound/ui/tui_time_delete.go +++ b/adapter/inbound/ui/tui_time_delete.go @@ -86,15 +86,15 @@ func (m *AppModel) updateTimeDeletePopup(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *AppModel) viewTimeDeletePopup() string { border := lipgloss.NewStyle(). Border(appBorder). - BorderForeground(lipgloss.Color("#8aadf4")). + BorderForeground(AppTheme.Secondary). Padding(1, 2) - title := lipgloss.NewStyle().Foreground(lipgloss.Color("#c6a0f6")).Bold(true).Render("Delete By Time") - desc := lipgloss.NewStyle().Foreground(lipgloss.Color("#cad3f5")).Render("Delete messages older than:") + title := lipgloss.NewStyle().Foreground(AppTheme.Primary).Bold(true).Render("Delete By Time") + desc := lipgloss.NewStyle().Foreground(AppTheme.Text).Render("Delete messages older than:") - valColor := lipgloss.Color("#181926") + valColor := AppTheme.Background if m.timeDeleteFocus != 0 { - valColor = lipgloss.Color("#8087a2") + valColor = AppTheme.MutedText } valStr := lipgloss.NewStyle().Foreground(valColor).Render(m.timeDeleteVal) if m.timeDeleteVal == "" { @@ -102,11 +102,11 @@ func (m *AppModel) viewTimeDeletePopup() string { } unitStr := "days" - mStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8087a2")) - hStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8087a2")) - dStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8087a2")) + mStyle := lipgloss.NewStyle().Foreground(AppTheme.MutedText) + hStyle := lipgloss.NewStyle().Foreground(AppTheme.MutedText) + dStyle := lipgloss.NewStyle().Foreground(AppTheme.MutedText) - activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#181926")).Bold(true) + activeStyle := lipgloss.NewStyle().Foreground(AppTheme.Background).Bold(true) switch m.timeDeleteUnit { case "m": @@ -125,22 +125,22 @@ func (m *AppModel) viewTimeDeletePopup() string { dStyle.Render("(d)ays"), ) - bracketLeft := lipgloss.NewStyle().Foreground(lipgloss.Color("#8087a2")).Render("[ ") - bracketRight := lipgloss.NewStyle().Foreground(lipgloss.Color("#8087a2")).Render(" ]") + bracketLeft := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Render("[ ") + bracketRight := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Render(" ]") if m.timeDeleteFocus == 0 { - bracketLeft = lipgloss.NewStyle().Foreground(lipgloss.Color("#181926")).Render("[ ") - bracketRight = lipgloss.NewStyle().Foreground(lipgloss.Color("#181926")).Render(" ]") + bracketLeft = lipgloss.NewStyle().Foreground(AppTheme.Background).Render("[ ") + bracketRight = lipgloss.NewStyle().Foreground(AppTheme.Background).Render(" ]") } inputLine := fmt.Sprintf("%s%s%s %s", bracketLeft, valStr, bracketRight, units) - selectionInfo := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render(fmt.Sprintf("Current selection: %s %s", m.timeDeleteVal, unitStr)) + selectionInfo := lipgloss.NewStyle().Foreground(AppTheme.Warning).Render(fmt.Sprintf("Current selection: %s %s", m.timeDeleteVal, unitStr)) if m.timeDeleteVal == "" { - selectionInfo = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render("Current selection: (invalid)") + selectionInfo = lipgloss.NewStyle().Foreground(AppTheme.Warning).Render("Current selection: (invalid)") } - btnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a5adcb")).Padding(0, 1) - focusedBtnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#181926")).Background(lipgloss.Color("#8aadf4")).Bold(true).Padding(0, 1) + btnStyle := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Padding(0, 1) + focusedBtnStyle := lipgloss.NewStyle().Foreground(AppTheme.Background).Background(AppTheme.Secondary).Bold(true).Padding(0, 1) delStyle := btnStyle canStyle := btnStyle diff --git a/adapter/inbound/ui/tui_update.go b/adapter/inbound/ui/tui_update.go index 5e28a19..2633675 100644 --- a/adapter/inbound/ui/tui_update.go +++ b/adapter/inbound/ui/tui_update.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "github.com/atotto/clipboard" "github.com/xvlet/amqcli/domain" "os" "sort" @@ -551,6 +552,16 @@ func (m *AppModel) updateMessageDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentState = stateMessageList m.selectedMessage = nil return m, nil + case "c", "C", "y", "Y": // Copy Payload + if m.selectedMessage != nil && m.selectedMessage.Body != "" { + err := clipboard.WriteAll(m.selectedMessage.Body) + if err != nil { + m.err = err + } else { + m.err = fmt.Errorf("success: Payload copied to clipboard") + } + } + return m, nil case "d", "D", "alt+d": // Delete if m.readOnly { m.err = fmt.Errorf("read-only mode is active") diff --git a/adapter/inbound/ui/tui_view.go b/adapter/inbound/ui/tui_view.go index 4ce3795..fc38178 100644 --- a/adapter/inbound/ui/tui_view.go +++ b/adapter/inbound/ui/tui_view.go @@ -14,7 +14,7 @@ func (m *AppModel) View() string { // no headerOk anymore // Dynamic colors based on profile - dimPipe := lipgloss.NewStyle().Foreground(lipgloss.Color("#6e738d")).Render(" | ") + dimPipe := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Render(" | ") var brokerInfoStr string var topUsageLeft, topUsageRight string @@ -22,7 +22,7 @@ func (m *AppModel) View() string { brokerInfoStr = fmt.Sprintf(" [%s]", m.brokerInfo) if m.viewStats { - cyanStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#91d7e3")).Render + cyanStyle := lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Info).Render storeStr := fmt.Sprintf("Store %d%%", m.brokerStats.StorePercentUsage) if m.brokerStats.StoreLimit > 0 { @@ -47,17 +47,17 @@ func (m *AppModel) View() string { envStyle := lipgloss.NewStyle().Bold(true) if strings.Contains(strings.ToLower(m.env), "prod") { - envStyle = envStyle.Foreground(lipgloss.Color("#ed8796")) // Red for PROD + envStyle = envStyle.Foreground(AppTheme.Error) // Red for PROD } else { - envStyle = envStyle.Foreground(lipgloss.Color("#a6da95")) // Green for DEV/others + envStyle = envStyle.Foreground(AppTheme.Success) // Green for DEV/others } envStr := envStyle.Render(fmt.Sprintf("[%s] ", m.env)) var connPart string - if m.err != nil && !strings.Contains(m.err.Error(), "read-only") && !strings.Contains(m.err.Error(), "snapshot saved") { - connPart = envStr + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#ed8796")).Render(fmt.Sprintf("%s Disconnected", m.host)) + if m.err != nil && !strings.Contains(m.err.Error(), "read-only") && !strings.Contains(m.err.Error(), "snapshot saved") && !strings.HasPrefix(m.err.Error(), "success: ") && !strings.Contains(m.err.Error(), "snapshot") { + connPart = envStr + lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Error).Render(fmt.Sprintf("%s Disconnected", m.host)) } else { - connPart = envStr + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#a6da95")).Render(fmt.Sprintf("%s Connected%s", m.host, brokerInfoStr)) + connPart = envStr + lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Success).Render(fmt.Sprintf("%s Connected%s", m.host, brokerInfoStr)) } colors := []string{"#a6da95", "#9cdec4", "#91e1d3", "#87e5e2", "#8bd5ca", "#81c8be"} @@ -85,8 +85,8 @@ func (m *AppModel) View() string { } // Create cohesive Box Footer with Left & Right text perfectly spanning box - y := lipgloss.NewStyle().Foreground(lipgloss.Color("#eed49f")).Render - g := lipgloss.NewStyle().Foreground(lipgloss.Color("#a5adcb")).Render + y := lipgloss.NewStyle().Foreground(AppTheme.ActiveItem).Render + g := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Render timeStr := m.lastUpdated.Format("2006-01-02 15:04:05") lastUpdatedText := g(fmt.Sprintf("Last Updated: %s", timeStr)) @@ -94,7 +94,7 @@ func (m *AppModel) View() string { bottomLeftCol := connPart bottomRightBase := fmt.Sprintf("%s%s", dimPipe, lastUpdatedText) - versionStr := lipgloss.NewStyle().Foreground(lipgloss.Color("#5b6078")).Render(fmt.Sprintf("amqcli (%s)", config.Version)) + versionStr := lipgloss.NewStyle().Foreground(AppTheme.Border).Render(fmt.Sprintf("amqcli (%s)", config.Version)) topRightWidth := lipgloss.Width(topUsageRight) padLen := topRightWidth - lipgloss.Width(bottomRightBase) - lipgloss.Width(versionStr) @@ -129,19 +129,22 @@ func (m *AppModel) View() string { if strings.Contains(m.err.Error(), "snapshot") { // Capitalize snapshot to Snapshot msg := strings.Replace(m.err.Error(), "snapshot", "Snapshot", 1) - notice = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#a6da95")).Render(fmt.Sprintf("✔ %v", msg)) + notice = lipgloss.NewStyle().MarginLeft(2).Foreground(AppTheme.Success).Render(fmt.Sprintf("✔ %v", msg)) + } else if strings.HasPrefix(m.err.Error(), "success: ") { + msg := strings.TrimPrefix(m.err.Error(), "success: ") + notice = lipgloss.NewStyle().MarginLeft(2).Foreground(AppTheme.Success).Render(fmt.Sprintf("✔ %v", msg)) } else { - notice = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#ed8796")).Render(fmt.Sprintf("⚠ %v", m.err)) + notice = lipgloss.NewStyle().MarginLeft(2).Foreground(AppTheme.Error).Render(fmt.Sprintf("⚠ %v", m.err)) } footerOk = lipgloss.JoinVertical(lipgloss.Left, footerOk, notice) } if m.currentState == stateList { - subHeader := lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#a5adcb")).Render(fmt.Sprintf("Command: [%s]reate%s[%s]end to%s[%s]urge%s[%s]elete%s<%s> Browse%s<%s>nfo%sCo<%s>nections%s<%s>sage%s<%s> Snapshot(Full)", + subHeader := lipgloss.NewStyle().MarginLeft(2).Foreground(AppTheme.MutedText).Render(fmt.Sprintf("Command: [%s]reate%s[%s]end to%s[%s]urge%s[%s]elete%s<%s> Browse%s<%s>nfo%sCo<%s>nections%s<%s>sage%s<%s> Snapshot(Full)", y("C"), dimPipe, y("S"), dimPipe, y("P"), dimPipe, y("D"), dimPipe, y("Enter"), dimPipe, y("I"), dimPipe, y("n"), dimPipe, y("U"), dimPipe, y("o"))) // Use global contentW dimension - tableBox := lipgloss.NewStyle().MarginLeft(2).Border(appBorder).BorderForeground(lipgloss.Color("#5b6078")).Padding(0, 1).Width(contentW).Render(m.queueTable.View()) + tableBox := lipgloss.NewStyle().MarginLeft(2).Border(appBorder).BorderForeground(AppTheme.Border).Padding(0, 1).Width(contentW).Render(m.queueTable.View()) return lipgloss.JoinVertical(lipgloss.Left, wrapHeader(subHeader), tableBox, footerOk) } @@ -156,22 +159,22 @@ func (m *AppModel) View() string { } searchHint := fmt.Sprintf("[%s] Search", y("F3|Ctrl+F")) if m.isFiltered && m.filterKeyword != "" { - searchHint += lipgloss.NewStyle().Foreground(lipgloss.Color("#cad3f5")).Render(fmt.Sprintf(" (%s)", m.filterKeyword)) + searchHint += lipgloss.NewStyle().Foreground(AppTheme.Text).Render(fmt.Sprintf(" (%s)", m.filterKeyword)) } sub := lipgloss.NewStyle().MarginLeft(2).Render(fmt.Sprintf("Browsing: %s (%d/%d) | Command: <%s> Back | <%s> Detail | <%s> Select | %s | [%s] Delete | [%s] Delete By Time", m.selectedQueue, len(m.messages), pending, y("Esc"), y("Enter"), y("Space"), searchHint, y("d"), y("p"))) // Use global contentW dimension - tableBox := lipgloss.NewStyle().MarginLeft(2).Border(appBorder).BorderForeground(lipgloss.Color("#5b6078")).Padding(0, 1).Width(contentW).Render(m.msgTable.View()) + tableBox := lipgloss.NewStyle().MarginLeft(2).Border(appBorder).BorderForeground(AppTheme.Border).Padding(0, 1).Width(contentW).Render(m.msgTable.View()) return lipgloss.JoinVertical(lipgloss.Left, wrapHeader(sub), tableBox, footerOk) } if m.currentState == stateMessageDetail && m.selectedMessage != nil { - sub := lipgloss.NewStyle().MarginLeft(2).Render(fmt.Sprintf("Browsing: %s | Command: <%s> Back | [%s]elete | [%s]etry | [%s]ove", m.selectedQueue, y("Esc"), y("D"), y("R"), y("M"))) + sub := lipgloss.NewStyle().MarginLeft(2).Render(fmt.Sprintf("Browsing: %s | Command: <%s> Back | [%s]opy | [%s]elete | [%s]etry | [%s]ove", m.selectedQueue, y("Esc"), y("C"), y("D"), y("R"), y("M"))) // Fixed-width bracketed label for uniform alignment, rendered in gray // ' Correlation ID ' = 16 runes → const w = 16 so all labels align - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#cad3f5")) + labelStyle := lipgloss.NewStyle().Foreground(AppTheme.Text) lbl := func(s string) string { const w = 16 padded := s @@ -210,7 +213,7 @@ func (m *AppModel) View() string { wrappedText := wrapText(b.String(), innerWidth) m.viewport.SetContent(wrappedText) - detailBox := lipgloss.NewStyle().MarginLeft(2).Padding(1, 2).Border(appBorder).BorderForeground(lipgloss.Color("#5b6078")).Width(contentW).Render(m.viewport.View()) + detailBox := lipgloss.NewStyle().MarginLeft(2).Padding(1, 2).Border(appBorder).BorderForeground(AppTheme.Border).Width(contentW).Render(m.viewport.View()) return lipgloss.JoinVertical(lipgloss.Left, wrapHeader(sub), detailBox, footerOk) } @@ -221,13 +224,13 @@ func (m *AppModel) View() string { if m.selectedQueueDetail == nil { var b strings.Builder b.WriteString("\n Loading queue details...") - infoBox := lipgloss.NewStyle().MarginLeft(2).Padding(1, 2).Border(appBorder).BorderForeground(lipgloss.Color("#5b6078")).Width(contentW).Render(b.String()) + infoBox := lipgloss.NewStyle().MarginLeft(2).Padding(1, 2).Border(appBorder).BorderForeground(AppTheme.Border).Width(contentW).Render(b.String()) return lipgloss.JoinVertical(lipgloss.Left, wrapHeader(sub), infoBox, footerOk) } qd := m.selectedQueueDetail // Label helper for alignment - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#cad3f5")) + labelStyle := lipgloss.NewStyle().Foreground(AppTheme.Text) lbl := func(s string) string { const w = 20 padded := s @@ -240,7 +243,7 @@ func (m *AppModel) View() string { // Indentation and Style definitions indent := " " var b strings.Builder - fmt.Fprintf(&b, "\n%s%s\n", indent, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c6a0f6")).Render(" [ Queue Statistics ] ")) + fmt.Fprintf(&b, "\n%s%s\n", indent, lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Primary).Render(" [ Queue Statistics ] ")) // Stat rows fmt.Fprintf(&b, "%s %s %-20s %s %d\n", indent, lbl(" Name "), qd.Name, lbl(" Queue Size "), qd.QueueSize) @@ -250,7 +253,7 @@ func (m *AppModel) View() string { fmt.Fprintf(&b, "%s %s %-20s %s %s\n", indent, lbl(" Store Size "), formatBytes(qd.StoreMessageSize), lbl(" Total Enqueued "), formatWithCommas(qd.EnqueueCount)) fmt.Fprintf(&b, "%s %s %-20s %s %.2f ms\n\n", indent, lbl(" Total Dequeued "), formatWithCommas(qd.DequeueCount), lbl(" Avg Blocked Time "), qd.AverageBlockedTime) - fmt.Fprintf(&b, "%s%s\n", indent, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c6a0f6")).Render(" [ Active Consumers ] ")) + fmt.Fprintf(&b, "%s%s\n", indent, lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Primary).Render(" [ Active Consumers ] ")) statsStr := b.String() @@ -262,7 +265,7 @@ func (m *AppModel) View() string { lines := strings.Split(rawTableStr, "\n") if len(lines) > 0 { // Top long separator line - longLine := lipgloss.NewStyle().Foreground(lipgloss.Color("#5b6078")).Render(strings.Repeat("-", contentW-4)) + longLine := lipgloss.NewStyle().Foreground(AppTheme.Border).Render(strings.Repeat("-", contentW-4)) newLines := append([]string{longLine}, lines...) tableStr = strings.Join(newLines, "\n") } else { @@ -271,7 +274,7 @@ func (m *AppModel) View() string { } combined := lipgloss.JoinVertical(lipgloss.Left, statsStr, tableStr) - infoBox := lipgloss.NewStyle().MarginLeft(2).Padding(0, 1).Border(appBorder).BorderForeground(lipgloss.Color("#5b6078")).Width(contentW).Render(combined) + infoBox := lipgloss.NewStyle().MarginLeft(2).Padding(0, 1).Border(appBorder).BorderForeground(AppTheme.Border).Width(contentW).Render(combined) return lipgloss.JoinVertical(lipgloss.Left, wrapHeader(sub), infoBox, footerOk) } @@ -279,7 +282,7 @@ func (m *AppModel) View() string { if m.currentState == stateConnections { sub := lipgloss.NewStyle().MarginLeft(2).Render(fmt.Sprintf("Connections: %d | Command: <%s> Back", len(m.connections), y("Esc"))) - tableBox := lipgloss.NewStyle().MarginLeft(2).Border(appBorder).BorderForeground(lipgloss.Color("#5b6078")).Padding(0, 1).Width(contentW).Render(m.connectionsTable.View()) + tableBox := lipgloss.NewStyle().MarginLeft(2).Border(appBorder).BorderForeground(AppTheme.Border).Padding(0, 1).Width(contentW).Render(m.connectionsTable.View()) return lipgloss.JoinVertical(lipgloss.Left, wrapHeader(sub), tableBox, footerOk) } @@ -288,7 +291,7 @@ func (m *AppModel) View() string { if m.currentState == stateConfirmDelete || m.currentState == stateConfirmMultiDelete { var title, body string if m.currentState == stateConfirmMultiDelete { - title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c6a0f6")).Render("Delete Multiple Messages") + title = lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Primary).Render("Delete Multiple Messages") selectedCount := 0 for _, sel := range m.selectedMessages { if sel { @@ -297,14 +300,14 @@ func (m *AppModel) View() string { } body = fmt.Sprintf("Are you sure you want to delete %d selected message(s)?", selectedCount) } else { - title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c6a0f6")).Render("Delete Queue") + title = lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Primary).Render("Delete Queue") body = fmt.Sprintf("Are you sure you want to delete:\n\n %s", - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")).Render(m.confirmTarget)) + lipgloss.NewStyle().Bold(true).Foreground(AppTheme.Highlight).Render(m.confirmTarget)) } - okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a5adcb")).Padding(0, 1) - cancelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a5adcb")).Padding(0, 1) - focusedBtnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#181926")).Background(lipgloss.Color("#8aadf4")).Bold(true).Padding(0, 1) + okStyle := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Padding(0, 1) + cancelStyle := lipgloss.NewStyle().Foreground(AppTheme.MutedText).Padding(0, 1) + focusedBtnStyle := lipgloss.NewStyle().Foreground(AppTheme.Background).Background(AppTheme.Secondary).Bold(true).Padding(0, 1) if m.confirmFocus == 0 { okStyle = focusedBtnStyle @@ -317,7 +320,7 @@ func (m *AppModel) View() string { content := lipgloss.JoinVertical(lipgloss.Left, title, "", body, "", buttons) popup := lipgloss.NewStyle(). Border(appBorder). - BorderForeground(lipgloss.Color("#8aadf4")). + BorderForeground(AppTheme.Secondary). Padding(1, 3). Width(50). Render(content) diff --git a/usecase/usecase.go b/usecase/usecase.go index 7b6be88..30bf24c 100644 --- a/usecase/usecase.go +++ b/usecase/usecase.go @@ -14,77 +14,101 @@ import ( ) // ActiveMQUseCase groups all use definitions -type ActiveMQUseCase struct { +type UseCase interface { + GetBrokerInfo() (string, error) + GetBrokerStats() (domain.BrokerStats, error) + GetJVMStats() (domain.JVMStats, error) + GetQueues() ([]domain.Queue, error) + GetTopics() ([]domain.Topic, error) + GetConnections() ([]domain.Connection, error) + GetAllConsumers() ([]domain.Consumer, error) + GetQueueDetail(name string) (*domain.QueueDetail, error) + CreateQueue(name string) error + DeleteQueue(name string) error + PurgeQueue(name string) error + SendToQueue(queueName string, correlationID string, ttl time.Duration, body string) error + BrowseOldMessages(queueName string, correlationID string) ([]domain.Message, error) + DeleteOldMessages(queueName string, correlationID string) error + DeleteMessagesByTime(queueName string, olderThanStr string) error + BrowseQueue(name string) ([]domain.Message, error) + BrowseQueueWithPagination(name string, limit int, selector string) ([]domain.Message, error) + DeleteMessage(queueName string, messageID string) error + MoveMessage(queueName string, messageID string, destQueue string) error + RetryMessage(queueName string, messageID string) error + GetFullMessageBody(queueName string, messageID string) (string, error) +} + +type activeMQInteractor struct { queueRepo domain.QueueRepository messageRepo domain.MessageRepository encoding string } -func NewActiveMQUseCase(queueRepo domain.QueueRepository, messageRepo domain.MessageRepository, encoding string) *ActiveMQUseCase { - return &ActiveMQUseCase{ +func NewActiveMQUseCase(queueRepo domain.QueueRepository, messageRepo domain.MessageRepository, encoding string) UseCase { + return &activeMQInteractor{ queueRepo: queueRepo, messageRepo: messageRepo, encoding: encoding, } } -func (u *ActiveMQUseCase) GetBrokerInfo() (string, error) { +func (u *activeMQInteractor) GetBrokerInfo() (string, error) { return u.queueRepo.GetBrokerInfo() } -func (u *ActiveMQUseCase) GetBrokerStats() (domain.BrokerStats, error) { +func (u *activeMQInteractor) GetBrokerStats() (domain.BrokerStats, error) { return u.queueRepo.GetBrokerStats() } -func (u *ActiveMQUseCase) GetJVMStats() (domain.JVMStats, error) { +func (u *activeMQInteractor) GetJVMStats() (domain.JVMStats, error) { return u.queueRepo.GetJVMStats() } -func (u *ActiveMQUseCase) GetQueues() ([]domain.Queue, error) { +func (u *activeMQInteractor) GetQueues() ([]domain.Queue, error) { return u.queueRepo.GetQueues() } -func (u *ActiveMQUseCase) GetTopics() ([]domain.Topic, error) { +func (u *activeMQInteractor) GetTopics() ([]domain.Topic, error) { return u.queueRepo.GetTopics() } -func (u *ActiveMQUseCase) GetConnections() ([]domain.Connection, error) { +func (u *activeMQInteractor) GetConnections() ([]domain.Connection, error) { return u.queueRepo.GetConnections() } -func (u *ActiveMQUseCase) GetAllConsumers() ([]domain.Consumer, error) { +func (u *activeMQInteractor) GetAllConsumers() ([]domain.Consumer, error) { return u.queueRepo.GetAllConsumers() } -func (u *ActiveMQUseCase) GetQueueDetail(name string) (*domain.QueueDetail, error) { +func (u *activeMQInteractor) GetQueueDetail(name string) (*domain.QueueDetail, error) { return u.queueRepo.GetQueueDetail(name) } -func (u *ActiveMQUseCase) CreateQueue(name string) error { +func (u *activeMQInteractor) CreateQueue(name string) error { return u.queueRepo.CreateQueue(name) } -func (u *ActiveMQUseCase) DeleteQueue(name string) error { +func (u *activeMQInteractor) DeleteQueue(name string) error { return u.queueRepo.DeleteQueue(name) } -func (u *ActiveMQUseCase) PurgeQueue(name string) error { +func (u *activeMQInteractor) PurgeQueue(name string) error { return u.queueRepo.PurgeQueue(name) } -func (u *ActiveMQUseCase) SendToQueue(queueName string, correlationID string, ttl time.Duration, body string) error { +func (u *activeMQInteractor) SendToQueue(queueName string, correlationID string, ttl time.Duration, body string) error { return u.messageRepo.SendMessage(queueName, correlationID, ttl, body) } -func (u *ActiveMQUseCase) BrowseOldMessages(queueName string, correlationID string) ([]domain.Message, error) { +func (u *activeMQInteractor) BrowseOldMessages(queueName string, correlationID string) ([]domain.Message, error) { return u.messageRepo.BrowseMessagesByCorrelationID(queueName, correlationID) } -func (u *ActiveMQUseCase) DeleteOldMessages(queueName string, correlationID string) error { +func (u *activeMQInteractor) DeleteOldMessages(queueName string, correlationID string) error { return u.messageRepo.DeleteMessagesByCorrelationID(queueName, correlationID) } -func (u *ActiveMQUseCase) DeleteMessagesByTime(queueName string, olderThanStr string) error { +func (u *activeMQInteractor) DeleteMessagesByTime(queueName string, olderThanStr string) error { var duration time.Duration if strings.HasSuffix(olderThanStr, "d") || strings.HasSuffix(olderThanStr, "D") { daysStr := olderThanStr[:len(olderThanStr)-1] @@ -108,27 +132,27 @@ func (u *ActiveMQUseCase) DeleteMessagesByTime(queueName string, olderThanStr st return u.messageRepo.DeleteMessagesBySelector(queueName, selector) } -func (u *ActiveMQUseCase) BrowseQueue(name string) ([]domain.Message, error) { +func (u *activeMQInteractor) BrowseQueue(name string) ([]domain.Message, error) { return u.messageRepo.BrowseQueue(name) } -func (u *ActiveMQUseCase) BrowseQueueWithPagination(name string, limit int, selector string) ([]domain.Message, error) { +func (u *activeMQInteractor) BrowseQueueWithPagination(name string, limit int, selector string) ([]domain.Message, error) { return u.messageRepo.BrowseQueueWithPagination(name, limit, selector) } -func (u *ActiveMQUseCase) DeleteMessage(queueName string, messageID string) error { +func (u *activeMQInteractor) DeleteMessage(queueName string, messageID string) error { return u.queueRepo.RemoveMessage(queueName, messageID) } -func (u *ActiveMQUseCase) MoveMessage(queueName string, messageID string, destQueue string) error { +func (u *activeMQInteractor) MoveMessage(queueName string, messageID string, destQueue string) error { return u.queueRepo.MoveMessage(queueName, messageID, destQueue) } -func (u *ActiveMQUseCase) RetryMessage(queueName string, messageID string) error { +func (u *activeMQInteractor) RetryMessage(queueName string, messageID string) error { return u.queueRepo.RetryMessage(queueName, messageID) } -func (u *ActiveMQUseCase) GetFullMessageBody(queueName string, messageID string) (string, error) { +func (u *activeMQInteractor) GetFullMessageBody(queueName string, messageID string) (body string, retErr error) { // Use a unique timestamp-based name to avoid conflicts tempQueue := fmt.Sprintf("CLI.TEMP.%d", time.Now().UnixNano()) @@ -139,10 +163,16 @@ func (u *ActiveMQUseCase) GetFullMessageBody(queueName string, messageID string) } // Make sure we delete the temp queue afterward to avoid leaks - defer func() { _ = u.queueRepo.DeleteQueue(tempQueue) }() + defer func() { + if delErr := u.queueRepo.DeleteQueue(tempQueue); delErr != nil { + if retErr == nil { + retErr = fmt.Errorf("message read succeeded, but failed to delete temp queue %s: %v", tempQueue, delErr) + } + } + }() // 2. Consume the message via STOMP (fetches full payload regardless of size) - body, err := u.messageRepo.ConsumeMessageDestructive(tempQueue) + body, err = u.messageRepo.ConsumeMessageDestructive(tempQueue) if err != nil { return "", fmt.Errorf("failed to read full message via STOMP: %v", err) }