Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ import (
"github.com/clawscli/claws/internal/view"
)

// awsInitTimeout is the maximum time to wait for AWS context initialization
const awsInitTimeout = 5 * time.Second

// clearErrorMsg is sent to clear transient errors after a timeout
type clearErrorMsg struct{}

// awsContextReadyMsg is sent when AWS context initialization completes
type awsContextReadyMsg struct {
err error
}

// App is the main application model
// appStyles holds cached lipgloss styles for performance
type appStyles struct {
Expand Down Expand Up @@ -69,6 +77,9 @@ type App struct {
showWarnings bool
warningsReady bool // true after first render, to ignore initial terminal responses

// AWS initialization state
awsInitializing bool

// Cached styles
styles appStyles
}
Expand All @@ -87,19 +98,20 @@ func New(ctx context.Context, reg *registry.Registry) *App {

// Init implements tea.Model
func (a *App) Init() tea.Cmd {
// Initialize AWS context (detect region from IMDS, fetch account ID)
if err := aws.InitContext(a.ctx); err != nil {
config.Global().AddWarning("AWS init failed: " + err.Error())
}

// Show warnings if any
if len(config.Global().Warnings()) > 0 {
a.showWarnings = true
// Start with the service browser view immediately (no blocking on AWS calls)
a.currentView = view.NewServiceBrowser(a.ctx, a.registry)
a.awsInitializing = true

// Initialize AWS context in background (region detection, account ID fetch)
// Use timeout to avoid indefinite hang on network issues
initAWSCmd := func() tea.Msg {
ctx, cancel := context.WithTimeout(a.ctx, awsInitTimeout)
defer cancel()
err := aws.InitContext(ctx)
return awsContextReadyMsg{err: err}
}

// Start with the service browser view
a.currentView = view.NewServiceBrowser(a.ctx, a.registry)
return a.currentView.Init()
return tea.Batch(a.currentView.Init(), initAWSCmd)
}

// Update implements tea.Model
Expand Down Expand Up @@ -163,10 +175,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case tea.KeyMsg:
// Handle back navigation (esc or backspace)
// Check for ESC key in various forms (KeyEsc, KeyEscape, or raw ESC byte as KeyRunes)
isEsc := msg.String() == "esc" || msg.Type == tea.KeyEsc || msg.Type == tea.KeyEscape ||
(msg.Type == tea.KeyRunes && len(msg.Runes) == 1 && msg.Runes[0] == 27)
isBack := isEsc || msg.Type == tea.KeyBackspace
isBack := view.IsEscKey(msg) || msg.Type == tea.KeyBackspace

if isBack {
// If current view has active input, let it handle esc first
Expand Down Expand Up @@ -259,6 +268,16 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.err = nil
return a, nil

case awsContextReadyMsg:
a.awsInitializing = false
if msg.err != nil {
log.Debug("AWS context initialization failed", "error", msg.err)
config.Global().AddWarning("AWS init failed: " + msg.err.Error())
a.showWarnings = true
}
// Trigger a re-render to update header with account ID
return a, nil

case navmsg.RegionChangedMsg:
log.Info("region changed", "region", msg.Region)
// Pop views until we find a refreshable one (ResourceBrowser or ServiceBrowser)
Expand Down Expand Up @@ -365,6 +384,11 @@ func (a *App) View() string {
statusContent = roIndicator + " " + statusContent
}

// Add AWS initializing indicator
if a.awsInitializing {
statusContent = ui.DimStyle().Render("AWS initializing...") + " • " + statusContent
}

status := a.styles.status.Render(statusContent)

return content + "\n" + status
Expand Down
38 changes: 38 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,41 @@ func TestNavigationFlow(t *testing.T) {
t.Errorf("Expected viewStack length 0, got %d", len(app.viewStack))
}
}

func TestAWSContextReadyMsg_Success(t *testing.T) {
ctx := context.Background()
reg := registry.New()

app := New(ctx, reg)
app.awsInitializing = true

// Simulate successful AWS init
msg := awsContextReadyMsg{err: nil}
app.Update(msg)

if app.awsInitializing {
t.Error("Expected awsInitializing to be false after success")
}
if app.showWarnings {
t.Error("Expected showWarnings to be false after success")
}
}

func TestAWSContextReadyMsg_Timeout(t *testing.T) {
ctx := context.Background()
reg := registry.New()

app := New(ctx, reg)
app.awsInitializing = true

// Simulate timeout error
msg := awsContextReadyMsg{err: context.DeadlineExceeded}
app.Update(msg)

if app.awsInitializing {
t.Error("Expected awsInitializing to be false after timeout")
}
if !app.showWarnings {
t.Error("Expected showWarnings to be true after timeout")
}
}
13 changes: 12 additions & 1 deletion internal/ui/theme.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package ui

import "github.com/charmbracelet/lipgloss"
import (
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/lipgloss"
)

// Theme defines the color scheme for the application
type Theme struct {
Expand Down Expand Up @@ -101,3 +104,11 @@ func WarningStyle() lipgloss.Style {
func DangerStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(current.Danger)
}

// NewSpinner creates a consistently styled spinner for loading states
func NewSpinner() spinner.Model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(current.Accent)
return s
}
21 changes: 21 additions & 0 deletions internal/ui/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ func TestDangerStyle(t *testing.T) {
}
}

func TestNewSpinner(t *testing.T) {
s := NewSpinner()

// Spinner should be initialized
if s.Spinner.Frames == nil {
t.Error("NewSpinner() should have spinner frames")
}

// Should use Dot spinner (has specific frame count)
// spinner.Dot has 10 frames
if len(s.Spinner.Frames) == 0 {
t.Error("NewSpinner() should have non-empty frames")
}

// View should produce output
view := s.View()
if view == "" {
t.Error("NewSpinner().View() should produce output")
}
}

func TestThemeFields(t *testing.T) {
theme := DefaultTheme()

Expand Down
80 changes: 71 additions & 9 deletions internal/view/detail_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"context"
"strings"

"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/clawscli/claws/internal/action"
"github.com/clawscli/claws/internal/dao"
"github.com/clawscli/claws/internal/log"
"github.com/clawscli/claws/internal/registry"
"github.com/clawscli/claws/internal/render"
"github.com/clawscli/claws/internal/ui"
Expand Down Expand Up @@ -43,11 +45,15 @@ type DetailView struct {
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
spinner spinner.Model
styles detailViewStyles
}

// NewDetailView creates a new DetailView
func NewDetailView(ctx context.Context, resource dao.Resource, renderer render.Renderer, service, resType string, reg *registry.Registry) *DetailView {
func NewDetailView(ctx context.Context, resource dao.Resource, renderer render.Renderer, service, resType string, reg *registry.Registry, d dao.DAO) *DetailView {
hp := NewHeaderPanel()
hp.SetWidth(120) // Default width until SetSize is called

Expand All @@ -58,33 +64,81 @@ func NewDetailView(ctx context.Context, resource dao.Resource, renderer render.R
service: service,
resType: resType,
registry: reg,
dao: d,
headerPanel: hp,
spinner: ui.NewSpinner(),
styles: newDetailViewStyles(),
}
}

// detailRefreshMsg is sent when async resource refresh completes
type detailRefreshMsg struct {
resource dao.Resource
err error
}

// Init implements tea.Model
func (d *DetailView) Init() tea.Cmd {
// Start async refresh for extended details if DAO supports Get operation
if d.dao != nil && d.dao.Supports(dao.OpGet) {
d.refreshing = true
return tea.Batch(d.spinner.Tick, d.refreshResource)
}
return nil
}

// refreshResource fetches extended resource details in background
func (d *DetailView) refreshResource() tea.Msg {
if d.dao == nil || d.resource == nil {
return detailRefreshMsg{resource: d.resource}
}
refreshed, err := d.dao.Get(d.ctx, d.resource.GetID())
if err != nil {
return detailRefreshMsg{resource: d.resource, err: err}
}
return detailRefreshMsg{resource: refreshed}
}

// Update implements tea.Model
func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
// Check for esc (both string and raw byte) - let app handle back navigation
isEsc := keyMsg.String() == "esc" || keyMsg.Type == tea.KeyEsc || keyMsg.Type == tea.KeyEscape ||
(keyMsg.Type == tea.KeyRunes && len(keyMsg.Runes) == 1 && keyMsg.Runes[0] == 27)
if isEsc {
switch msg := msg.(type) {
case detailRefreshMsg:
d.refreshing = false
if msg.err != nil {
log.Warn("failed to refresh resource details", "error", msg.err)
d.refreshErr = msg.err
} else {
d.refreshErr = nil
d.resource = msg.resource
// Re-render content with refreshed data
if d.ready {
content := d.renderContent()
d.viewport.SetContent(content)
}
}
return d, nil

case spinner.TickMsg:
if d.refreshing {
var cmd tea.Cmd
d.spinner, cmd = d.spinner.Update(msg)
return d, cmd
}
return d, nil

case tea.KeyMsg:
// Let app handle back navigation
if IsEscKey(msg) {
return d, nil
}

// Check navigation shortcuts
if model, cmd := d.handleNavigation(keyMsg.String()); model != nil {
if model, cmd := d.handleNavigation(msg.String()); model != nil {
return model, cmd
}

// Open action menu (only if actions exist)
if keyMsg.String() == "a" {
if msg.String() == "a" {
if actions := action.Global.Get(d.service, d.resType); len(actions) > 0 {
actionMenu := NewActionMenu(d.ctx, d.resource, d.service, d.resType)
return d, func() tea.Msg {
Expand Down Expand Up @@ -176,7 +230,15 @@ func (d *DetailView) SetSize(width, height int) tea.Cmd {

// StatusLine implements View
func (d *DetailView) StatusLine() string {
parts := []string{d.resource.GetID(), "↑/↓:scroll"}
parts := []string{d.resource.GetID()}

if d.refreshing {
parts = append(parts, d.spinner.View()+" refreshing...")
} else if d.refreshErr != nil {
parts = append(parts, "⚠ refresh failed")
}

parts = append(parts, "↑/↓:scroll")

if actions := action.Global.Get(d.service, d.resType); len(actions) > 0 {
parts = append(parts, "a:actions")
Expand Down
Loading
Loading