diff --git a/.gitignore b/.gitignore index 83f638d1..6c92ad6f 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ claws.log # Node.js node_modules/ .claude +.gtrconfig diff --git a/README.md b/README.md index 31214eb8..fcebb408 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,7 @@ timeouts: multi_region_fetch: 60s # Multi-region parallel fetch timeout (default: 30s) tag_search: 45s # Tag search timeout (default: 30s) metrics_load: 30s # CloudWatch metrics load timeout (default: 30s) + log_fetch: 15s # CloudWatch Logs fetch timeout (default: 10s) concurrency: max_fetches: 100 # Max concurrent API fetches (default: 50) 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/cloudwatch/log-groups/actions.go b/custom/cloudwatch/log-groups/actions.go index c2b0d4db..eb02b42f 100644 --- a/custom/cloudwatch/log-groups/actions.go +++ b/custom/cloudwatch/log-groups/actions.go @@ -13,24 +13,6 @@ import ( func init() { action.Global.Register("cloudwatch", "log-groups", []action.Action{ - { - Name: action.ActionNameTailLogs, - Shortcut: "t", - Type: action.ActionTypeExec, - Command: `aws logs tail "${ID}" --since 1h --follow`, - }, - { - Name: action.ActionNameViewRecent1h, - Shortcut: "1", - Type: action.ActionTypeExec, - Command: `aws logs tail "${ID}" --since 1h | less -R`, - }, - { - Name: action.ActionNameViewRecent24h, - Shortcut: "2", - Type: action.ActionTypeExec, - Command: `aws logs tail "${ID}" --since 24h | less -R`, - }, { Name: "Delete", Shortcut: "D", diff --git a/custom/cloudwatch/log-groups/render.go b/custom/cloudwatch/log-groups/render.go index 2527160e..788ad8dc 100644 --- a/custom/cloudwatch/log-groups/render.go +++ b/custom/cloudwatch/log-groups/render.go @@ -34,14 +34,14 @@ func NewLogGroupRenderer() render.Renderer { } func getSize(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { return render.FormatSize(lg.StoredBytes()) } return "-" } func getRetention(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { days := lg.RetentionDays() if days == 0 { return "Never" @@ -55,7 +55,7 @@ func getRetention(r dao.Resource) string { } func getClass(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { class := lg.LogGroupClass() if class == "" || class == "STANDARD" { return "Standard" @@ -66,7 +66,7 @@ func getClass(r dao.Resource) string { } func getAge(r dao.Resource) string { - if lg, ok := r.(*LogGroupResource); ok { + if lg, ok := dao.UnwrapResource(r).(*LogGroupResource); ok { creationTime := lg.CreationTime() if creationTime > 0 { t := time.UnixMilli(creationTime) @@ -78,7 +78,7 @@ func getAge(r dao.Resource) string { // RenderDetail renders detailed log group information func (r *LogGroupRenderer) RenderDetail(resource dao.Resource) string { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return "" } @@ -138,7 +138,7 @@ func (r *LogGroupRenderer) RenderDetail(resource dao.Resource) string { // RenderSummary returns summary fields for the header panel func (r *LogGroupRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return r.BaseRenderer.RenderSummary(resource) } @@ -172,14 +172,18 @@ func (r *LogGroupRenderer) RenderSummary(resource dao.Resource) []render.Summary return fields } -// Navigations returns navigation shortcuts func (r *LogGroupRenderer) Navigations(resource dao.Resource) []render.Navigation { - lg, ok := resource.(*LogGroupResource) + lg, ok := dao.UnwrapResource(resource).(*LogGroupResource) if !ok { return nil } return []render.Navigation{ + { + Key: "t", + Label: "Tail", + ViewType: render.ViewTypeLogView, + }, { Key: "s", Label: "Streams", diff --git a/custom/cloudwatch/log-streams/actions.go b/custom/cloudwatch/log-streams/actions.go index 24a3f81b..a4502230 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -12,26 +12,7 @@ import ( ) func init() { - // Register actions for CloudWatch Log Streams action.Global.Register("cloudwatch", "log-streams", []action.Action{ - { - Name: action.ActionNameTailLogs, - Shortcut: "t", - Type: action.ActionTypeExec, - Command: `aws logs tail "${LOG_GROUP}" --log-stream-names "${NAME}" --since 1h --follow`, - }, - { - Name: action.ActionNameViewRecent1h, - Shortcut: "1", - Type: action.ActionTypeExec, - Command: `aws logs tail "${LOG_GROUP}" --log-stream-names "${NAME}" --since 1h | less -R`, - }, - { - Name: action.ActionNameViewRecent24h, - Shortcut: "2", - Type: action.ActionTypeExec, - Command: `aws logs tail "${LOG_GROUP}" --log-stream-names "${NAME}" --since 24h | less -R`, - }, { Name: "Delete", Shortcut: "D", @@ -41,11 +22,9 @@ func init() { }, }) - // Register executor action.RegisterExecutor("cloudwatch", "log-streams", executeLogStreamAction) } -// executeLogStreamAction executes an action on a CloudWatch Log Stream func executeLogStreamAction(ctx context.Context, act action.Action, resource dao.Resource) action.ActionResult { switch act.Operation { case "DeleteLogStream": @@ -55,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 := resource.(*LogStreamResource) + 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/custom/cloudwatch/log-streams/dao.go b/custom/cloudwatch/log-streams/dao.go index c632b867..b684efa4 100644 --- a/custom/cloudwatch/log-streams/dao.go +++ b/custom/cloudwatch/log-streams/dao.go @@ -117,6 +117,9 @@ func (d *LogStreamDAO) Delete(ctx context.Context, id string) error { _, err := d.client.DeleteLogStream(ctx, input) if err != nil { + if apperrors.IsNotFound(err) { + return nil + } return apperrors.Wrapf(err, "delete log stream %s", id) } diff --git a/custom/cloudwatch/log-streams/render.go b/custom/cloudwatch/log-streams/render.go index 1a49714b..cdbe88f0 100644 --- a/custom/cloudwatch/log-streams/render.go +++ b/custom/cloudwatch/log-streams/render.go @@ -31,7 +31,7 @@ func NewLogStreamRenderer() render.Renderer { } func getLastEvent(r dao.Resource) string { - if ls, ok := r.(*LogStreamResource); ok { + if ls, ok := dao.UnwrapResource(r).(*LogStreamResource); ok { lastEvent := ls.LastEventTimestamp() if lastEvent > 0 { t := time.UnixMilli(lastEvent) @@ -42,7 +42,7 @@ func getLastEvent(r dao.Resource) string { } func getAge(r dao.Resource) string { - if ls, ok := r.(*LogStreamResource); ok { + if ls, ok := dao.UnwrapResource(r).(*LogStreamResource); ok { creationTime := ls.CreationTime() if creationTime > 0 { t := time.UnixMilli(creationTime) @@ -54,7 +54,7 @@ func getAge(r dao.Resource) string { // RenderDetail renders detailed log stream information func (r *LogStreamRenderer) RenderDetail(resource dao.Resource) string { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return "" } @@ -95,7 +95,7 @@ func (r *LogStreamRenderer) RenderDetail(resource dao.Resource) string { // RenderSummary returns summary fields for the header panel func (r *LogStreamRenderer) RenderSummary(resource dao.Resource) []render.SummaryField { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return r.BaseRenderer.RenderSummary(resource) } @@ -118,14 +118,18 @@ func (r *LogStreamRenderer) RenderSummary(resource dao.Resource) []render.Summar return fields } -// Navigations returns navigation shortcuts func (r *LogStreamRenderer) Navigations(resource dao.Resource) []render.Navigation { - ls, ok := resource.(*LogStreamResource) + ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return nil } return []render.Navigation{ + { + Key: "t", + Label: "Tail", + ViewType: render.ViewTypeLogView, + }, { Key: "g", Label: "Log Group", 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/action/action.go b/internal/action/action.go index b74664eb..4a656b77 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -44,7 +44,6 @@ type ActionType string const ( ActionTypeExec ActionType = "exec" ActionTypeAPI ActionType = "api" - ActionTypeView ActionType = "view" ) type ConfirmLevel int @@ -55,15 +54,9 @@ const ( ConfirmDangerous ) -// Action names - used for read-only allowlist and cross-package references const ( ActionNameSSOLogin = "SSO Login" - ActionNameLogin = "Login" // :login command - console login - - // Read-only safe exec actions (read-only operations) - ActionNameTailLogs = "Tail Logs" - ActionNameViewRecent1h = "View Recent (1h)" - ActionNameViewRecent24h = "View Recent (24h)" + ActionNameLogin = "Login" ) type Action struct { @@ -72,7 +65,6 @@ type Action struct { Type ActionType Command string Operation string - Target string Confirm ConfirmLevel // SkipAWSEnv skips AWS env injection for exec commands. @@ -171,28 +163,14 @@ var ReadOnlyAllowlist = map[string]bool{ "InvokeFunctionDryRun": true, } -// ReadOnlyExecAllowlist defines exec actions allowed in read-only mode. -// Auth workflows and read-only operations are allowed. -// Arbitrary shells (ECS Exec, SSM Session) are denied - they provide -// interactive access that could modify resources. -// -// Security rationale for each allowed action: var ReadOnlyExecAllowlist = map[string]bool{ - // SSO Login: Authentication workflow, no resource changes ActionNameSSOLogin: true, - // Login: Opens browser for console login, no resource changes - ActionNameLogin: true, - // Log viewing: Read-only CloudWatch Logs access - ActionNameTailLogs: true, - ActionNameViewRecent1h: true, - ActionNameViewRecent24h: true, + ActionNameLogin: true, } // IsAllowedInReadOnly returns whether the action can be executed in read-only mode. func IsAllowedInReadOnly(act Action) bool { switch act.Type { - case ActionTypeView: - return true case ActionTypeExec: return ReadOnlyExecAllowlist[act.Name] case ActionTypeAPI: diff --git a/internal/action/action_test.go b/internal/action/action_test.go index d0d17611..358715af 100644 --- a/internal/action/action_test.go +++ b/internal/action/action_test.go @@ -254,7 +254,6 @@ func TestIsAllowedInReadOnly(t *testing.T) { act Action want bool }{ - {"view type allowed", Action{Type: ActionTypeView}, true}, {"exec allowlisted", Action{Type: ActionTypeExec, Name: ActionNameLogin}, true}, {"exec not allowlisted", Action{Type: ActionTypeExec, Name: "SomeExec"}, false}, {"api allowlisted", Action{Type: ActionTypeAPI, Operation: "DetectStackDrift"}, true}, @@ -410,7 +409,6 @@ func TestActionType(t *testing.T) { }{ {ActionTypeExec, "exec"}, {ActionTypeAPI, "api"}, - {ActionTypeView, "view"}, } for _, tt := range tests { @@ -592,7 +590,6 @@ func TestAction_Struct(t *testing.T) { Type: ActionTypeAPI, Command: "test cmd", Operation: "TestOp", - Target: "ec2/instances", Confirm: ConfirmSimple, } @@ -870,25 +867,6 @@ func TestReadOnlyEnforcement_ExecuteWithDAO(t *testing.T) { } }) - t.Run("read-only always allows view actions", func(t *testing.T) { - // Enable read-only mode - config.Global().SetReadOnly(true) - defer config.Global().SetReadOnly(false) - - action := Action{ - Name: "View Details", - Type: ActionTypeView, - Target: "ec2/instances", - } - - result := ExecuteWithDAO(context.Background(), action, &mockResource{id: "test"}, "ec2", "instances") - - // View actions are always allowed - should not return ErrReadOnlyDenied - if result.Error == ErrReadOnlyDenied { - t.Error("read-only should not block view actions") - } - }) - t.Run("non-read-only allows all actions", func(t *testing.T) { // Ensure read-only mode is disabled config.Global().SetReadOnly(false) diff --git a/internal/action/exec_with_header.go b/internal/action/exec_with_header.go index 720bce37..a0a696b9 100644 --- a/internal/action/exec_with_header.go +++ b/internal/action/exec_with_header.go @@ -1,13 +1,13 @@ package action import ( + "cmp" "fmt" "io" "os" "os/exec" "strings" - "charm.land/lipgloss/v2" "golang.org/x/term" "github.com/clawscli/claws/internal/aws" @@ -18,9 +18,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) } @@ -149,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) @@ -186,7 +183,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) @@ -210,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), } @@ -252,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/app/app.go b/internal/app/app.go index dd8bb945..9b5a2e7b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,12 +48,12 @@ 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), - warningDim: lipgloss.NewStyle().Foreground(t.TextDim).MarginTop(1), - warningBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Pending).Padding(1, 2), + warningTitle: ui.BoldPendingStyle().MarginBottom(1), + warningItem: ui.WarningStyle(), + warningDim: ui.DimStyle().MarginTop(1), + warningBox: ui.BoxStyle().BorderForeground(t.Pending).Padding(1, 2), } } @@ -212,7 +212,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, a.keys.Quit): switch a.currentView.(type) { - case *view.DetailView, *view.DiffView: + case *view.DetailView, *view.DiffView, *view.LogView: if len(a.viewStack) > 0 { a.currentView = a.viewStack[len(a.viewStack)-1] a.viewStack = a.viewStack[:len(a.viewStack)-1] diff --git a/internal/aws/helpers.go b/internal/aws/helpers.go index 41bd1059..4d09bdc3 100644 --- a/internal/aws/helpers.go +++ b/internal/aws/helpers.go @@ -53,6 +53,11 @@ func Int32Ptr(i int32) *int32 { return aws.Int32(i) } +// Int64Ptr returns a pointer to the given int64. +func Int64Ptr(i int64) *int64 { + return aws.Int64(i) +} + // ExtractResourceName extracts the resource name from an AWS ARN. // e.g., "arn:aws:iam::123456789012:role/MyRole" -> "MyRole" // e.g., "arn:aws:ecs:us-east-1:123456789012:cluster/my-cluster" -> "my-cluster" diff --git a/internal/config/file.go b/internal/config/file.go index 00e84f6a..62b98762 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -16,6 +16,7 @@ const ( DefaultMultiRegionFetchTimeout = 30 * time.Second DefaultTagSearchTimeout = 30 * time.Second DefaultMetricsLoadTimeout = 30 * time.Second + DefaultLogFetchTimeout = 10 * time.Second DefaultMetricsWindow = 15 * time.Minute DefaultMaxConcurrentFetches = 50 ) @@ -41,6 +42,7 @@ type TimeoutConfig struct { MultiRegionFetch Duration `yaml:"multi_region_fetch,omitempty"` TagSearch Duration `yaml:"tag_search,omitempty"` MetricsLoad Duration `yaml:"metrics_load,omitempty"` + LogFetch Duration `yaml:"log_fetch,omitempty"` } type CloudWatchConfig struct { @@ -105,6 +107,7 @@ func DefaultFileConfig() *FileConfig { MultiRegionFetch: Duration(DefaultMultiRegionFetchTimeout), TagSearch: Duration(DefaultTagSearchTimeout), MetricsLoad: Duration(DefaultMetricsLoadTimeout), + LogFetch: Duration(DefaultLogFetchTimeout), }, Concurrency: ConcurrencyConfig{ MaxFetches: DefaultMaxConcurrentFetches, @@ -224,6 +227,9 @@ func (c *FileConfig) applyDefaults() { if c.Timeouts.MetricsLoad <= 0 { c.Timeouts.MetricsLoad = Duration(DefaultMetricsLoadTimeout) } + if c.Timeouts.LogFetch <= 0 { + c.Timeouts.LogFetch = Duration(DefaultLogFetchTimeout) + } if c.CloudWatch.Window <= 0 { c.CloudWatch.Window = Duration(DefaultMetricsWindow) } @@ -268,6 +274,15 @@ func (c *FileConfig) MetricsLoadTimeout() time.Duration { }) } +func (c *FileConfig) LogFetchTimeout() time.Duration { + return withRLock(&c.mu, func() time.Duration { + if c.Timeouts.LogFetch == 0 { + return DefaultLogFetchTimeout + } + return c.Timeouts.LogFetch.Duration() + }) +} + func (c *FileConfig) MaxConcurrentFetches() int { return withRLock(&c.mu, func() int { if c.Concurrency.MaxFetches == 0 { diff --git a/internal/render/detail.go b/internal/render/detail.go index 2e44f9ed..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: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), - Section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).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), + Title: ui.TitleStyle(), + Section: ui.SectionStyle().MarginTop(1), + 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 2bc5662c..2699536f 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -26,16 +26,20 @@ type SummaryField struct { Style lipgloss.Style // Optional styling for the value } -// Navigation defines a navigation shortcut to related resources +// ViewTypeLogView indicates navigation should open a LogView instead of ResourceBrowser +const ViewTypeLogView = "log-view" + +// Navigation defines a navigation shortcut to related resources or custom views type Navigation struct { - Key string // Shortcut key (e.g., "s" for subnets) - Label string // Display label (e.g., "Subnets") - Service string // Target service (e.g., "vpc") - Resource string // Target resource type (e.g., "subnets") - FilterField string // Field name to filter by (e.g., "VpcId") - FilterValue string // Value to filter by (extracted from current resource) - AutoReload bool // Enable auto-reload for this navigation - ReloadInterval time.Duration // Auto-reload interval (default: 3s) + Key string + Label string + Service string + Resource string + FilterField string + FilterValue string + AutoReload bool + ReloadInterval time.Duration + ViewType string } // Renderer defines the interface for rendering resources in table format @@ -124,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() } @@ -199,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 9af10b5c..d4ab2ee2 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -107,7 +107,109 @@ 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 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) +} + +// 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) +} + +// TextStyle returns a style for normal text +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) +} + +// 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) +} + +// 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 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) +} + +// 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 b36210f8..c8f1fc41 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -117,6 +117,174 @@ 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 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/action_menu.go b/internal/view/action_menu.go index 55b18205..2359aa4f 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -36,16 +36,16 @@ 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), - 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), + selected: ui.SelectedStyle().PaddingLeft(2), + shortcut: ui.SecondaryStyle(), + 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(), } } @@ -246,10 +246,7 @@ func (m *ActionMenu) getConfirmToken(act action.Action) string { func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { if act.Type == action.ActionTypeExec { - // Record action for post-exec follow-up handling m.lastExecAction = &act - - // For exec actions, use tea.Exec to suspend bubbletea execCmd, err := action.ExpandVariables(act.Command, m.resource) if err != nil { return m, func() tea.Msg { @@ -273,11 +270,8 @@ func (m *ActionMenu) executeAction(act action.Action) (tea.Model, tea.Cmd) { }) } - // For other actions, execute directly result := action.ExecuteWithDAO(m.ctx, act, m.resource, m.service, m.resType) m.result = &result - - // If action has a follow-up message, send it if result.FollowUpMsg != nil { log.Debug("action has follow-up message", "action", act.Name, "msgType", fmt.Sprintf("%T", result.FollowUpMsg)) return m, func() tea.Msg { return result.FollowUpMsg } @@ -349,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..8a6ea5e4 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -26,11 +26,10 @@ type commandInputStyles struct { } 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), - highlight: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), + input: ui.InputFieldStyle(), + suggestion: ui.DimStyle(), + highlight: ui.HighlightStyle(), } } @@ -55,7 +54,6 @@ type CommandInput struct { registry *registry.Registry textInput textinput.Model active bool - width int suggestions []string suggIdx int styles commandInputStyles @@ -195,7 +193,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 8f6a999e..208ca364 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -29,13 +29,12 @@ 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), - dim: lipgloss.NewStyle().Foreground(t.TextMuted), - highlight: lipgloss.NewStyle().Background(t.Selection).Foreground(t.SelectionText), + warning: ui.WarningStyle(), + danger: ui.DangerStyle(), + success: ui.SuccessStyle(), + dim: ui.MutedStyle(), + highlight: ui.SelectedStyle(), } } @@ -246,7 +245,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 +257,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 1d1842bb..118862d2 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -39,11 +39,8 @@ const ( ) func renderPanel(title, content string, width, height int, t *ui.Theme, hovered bool) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Primary) - boxHeight := height - 1 - if boxHeight < 3 { - boxHeight = 3 - } + titleStyle := ui.TitleStyle() + boxHeight := max(height-1, 3) borderColor := t.Border if hovered { @@ -62,24 +59,15 @@ 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) + barStyle := ui.AccentStyle() + emptyStyle := ui.MutedStyle() return barStyle.Render(strings.Repeat("█", filled)) + emptyStyle.Render(strings.Repeat("░", width-filled)) @@ -101,22 +89,15 @@ 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) + 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) @@ -151,8 +132,8 @@ 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++ { - line := " " + s.danger.Render("• ") + truncateValue(d.alarms[i].name, contentWidth-bulletIndentWidth) + for i := range maxShow { + line := " " + s.danger.Render("• ") + TruncateString(d.alarms[i].name, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } @@ -170,9 +151,9 @@ 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) + line := " " + s.warning.Render("• ") + TruncateString(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) if alarmCount+i == focusRow { line = s.highlight.Render(line) } @@ -209,13 +190,13 @@ 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" { 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) } @@ -256,13 +237,13 @@ 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" { 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/detail_view.go b/internal/view/detail_view.go index f4c38727..279dcde3 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" @@ -26,11 +25,10 @@ type detailViewStyles struct { } func newDetailViewStyles() detailViewStyles { - t := ui.Current() return detailViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), - label: lipgloss.NewStyle().Foreground(t.TextDim).Width(15), - value: lipgloss.NewStyle().Foreground(t.Text), + title: ui.TitleStyle(), + label: ui.DimStyle().Width(15), + value: ui.TextStyle(), } } @@ -40,15 +38,12 @@ 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 +105,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 +142,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 +166,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 { - return "Loading..." + if !d.vp.Ready { + return LoadingMessage } - // 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 @@ -199,10 +188,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 @@ -213,23 +198,12 @@ 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 - } + viewportHeight := max(height-headerHeight+1, 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 } @@ -286,7 +260,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/detail_view_test.go b/internal/view/detail_view_test.go index b5a7ed94..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}, }, { @@ -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..80c8514a 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -4,17 +4,14 @@ import ( "context" "strings" - "charm.land/bubbles/v2/viewport" 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" "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,27 +19,22 @@ type DiffView struct { renderer render.Renderer service string resourceType string - viewport viewport.Model - ready bool + vp ViewportState width int - height int styles diffViewStyles } type diffViewStyles struct { title lipgloss.Style header lipgloss.Style - content lipgloss.Style separator lipgloss.Style } func newDiffViewStyles() diffViewStyles { - t := ui.Current() return diffViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), - header: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary), - content: lipgloss.NewStyle().Foreground(t.Text), - separator: lipgloss.NewStyle().Foreground(t.TableBorder), + title: ui.TitleStyle(), + header: ui.SectionStyle(), + separator: ui.MutedStyle(), } } @@ -75,17 +67,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 { - return "Loading..." + if !d.vp.Ready { + return LoadingMessage } - return d.viewport.View() + return d.vp.Model.View() } // View implements tea.Model @@ -96,25 +87,15 @@ 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 - viewportHeight := height - headerHeight - if viewportHeight < 5 { - viewportHeight = 5 - } + viewportHeight := max(height-headerHeight, 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 } @@ -149,8 +130,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)) @@ -161,12 +142,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 := "" @@ -177,29 +155,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/diff_view_test.go b/internal/view/diff_view_test.go index 9573d92f..6ef1ef4c 100644 --- a/internal/view/diff_view_test.go +++ b/internal/view/diff_view_test.go @@ -49,23 +49,18 @@ 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) } - if dv.height != 50 { - t.Errorf("height = %d, want 50", dv.height) - } } func TestDiffView_Update_Esc(t *testing.T) { @@ -117,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/header_panel.go b/internal/view/header_panel.go index 21bc34f2..50682a0d 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -1,11 +1,11 @@ package view import ( + "cmp" "strconv" "strings" "charm.land/lipgloss/v2" - "github.com/mattn/go-runewidth" "github.com/clawscli/claws/internal/config" "github.com/clawscli/claws/internal/registry" @@ -21,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 { @@ -46,11 +36,11 @@ 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), - value: lipgloss.NewStyle().Foreground(t.Text), - accent: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), - dim: lipgloss.NewStyle().Foreground(t.TextMuted), - separator: lipgloss.NewStyle().Foreground(t.Border), + label: ui.DimStyle(), + value: ui.TextStyle(), + accent: ui.HighlightStyle(), + dim: ui.MutedStyle(), + separator: ui.BorderStyle(), } } @@ -77,19 +67,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 +100,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 +123,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 @@ -205,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/help_view.go b/internal/view/help_view.go index 836dd6f1..57cbd3cb 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" @@ -18,21 +17,17 @@ type helpViewStyles struct { } func newHelpViewStyles() helpViewStyles { - t := ui.Current() return helpViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), - section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), - key: lipgloss.NewStyle().Foreground(t.Success).Width(15), - desc: lipgloss.NewStyle().Foreground(t.Text), + title: ui.TitleStyle(), + section: ui.SectionStyle().MarginTop(1), + key: ui.SuccessStyle().Width(15), + desc: ui.TextStyle(), } } type HelpView struct { - width int - height int - styles helpViewStyles - viewport viewport.Model - ready bool + styles helpViewStyles + vp ViewportState } // NewHelpView creates a new HelpView @@ -47,10 +42,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 +150,11 @@ func (h *HelpView) renderContent() string { return out } -// ViewString returns the view content as a string func (h *HelpView) ViewString() string { - if !h.ready { - return "Loading..." + if !h.vp.Ready { + return LoadingMessage } - return h.viewport.View() + return h.vp.Model.View() } // View implements tea.Model @@ -169,19 +162,9 @@ 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 new file mode 100644 index 00000000..e5f4bc2d --- /dev/null +++ b/internal/view/log_view.go @@ -0,0 +1,417 @@ +package view + +import ( + "context" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" + + appaws "github.com/clawscli/claws/internal/aws" + "github.com/clawscli/claws/internal/config" + apperrors "github.com/clawscli/claws/internal/errors" + "github.com/clawscli/claws/internal/log" + "github.com/clawscli/claws/internal/ui" +) + +const ( + defaultLogPollInterval = 3 * time.Second + maxLogPollInterval = 30 * time.Second + initialLogBufferSize = 500 + maxLogBufferSize = 1000 + logFetchLimit = 100 + viewportHeaderOffset = 4 // header(1) + status(2) + spacing(1) +) + +type LogView struct { + ctx context.Context + client *cloudwatchlogs.Client + logGroupName string + logStreamName string + + vp ViewportState + spinner spinner.Model + styles logViewStyles + + logs []logEntry + loading bool + paused bool + err error + + lastEventTime int64 + oldestEventTime int64 + pollInterval time.Duration +} + +type logEntry struct { + timestamp time.Time + message string +} + +type logViewStyles struct { + header lipgloss.Style + timestamp lipgloss.Style + message lipgloss.Style + paused lipgloss.Style + error lipgloss.Style + dim lipgloss.Style +} + +func newLogViewStyles() logViewStyles { + return logViewStyles{ + header: ui.TitleStyle(), + timestamp: ui.SecondaryStyle(), + message: ui.TextStyle(), + paused: ui.BoldWarningStyle(), + error: ui.DangerStyle(), + dim: ui.DimStyle(), + } +} + +func NewLogView(ctx context.Context, logGroupName string) *LogView { + return &LogView{ + ctx: ctx, + logGroupName: logGroupName, + spinner: ui.NewSpinner(), + styles: newLogViewStyles(), + logs: make([]logEntry, 0, initialLogBufferSize), + loading: true, + pollInterval: defaultLogPollInterval, + } +} + +func NewLogViewWithStream(ctx context.Context, logGroupName, logStreamName string, lastEventTime int64) *LogView { + v := NewLogView(ctx, logGroupName) + v.logStreamName = logStreamName + if lastEventTime > 0 { + v.lastEventTime = lastEventTime - time.Hour.Milliseconds() + } + return v +} + +type logsLoadedMsg struct { + entries []logEntry + lastEventTime int64 + err error + throttled bool + older bool +} + +type logTickMsg time.Time + +func (v *LogView) Init() tea.Cmd { + return tea.Batch( + v.initClient, + v.spinner.Tick, + ) +} + +func (v *LogView) initClient() tea.Msg { + if err := v.ctx.Err(); err != nil { + return logsLoadedMsg{err: err} + } + cfg, err := appaws.NewConfig(v.ctx) + if err != nil { + return logsLoadedMsg{err: apperrors.Wrap(err, "init AWS config")} + } + v.client = cloudwatchlogs.NewFromConfig(cfg) + return v.doFetchLogs(v.lastEventTime, 0, false) +} + +func (v *LogView) fetchLogsCmd() tea.Cmd { + startTime := v.lastEventTime + return func() tea.Msg { + return v.doFetchLogs(startTime, 0, false) + } +} + +func (v *LogView) fetchOlderLogsCmd() tea.Cmd { + endTime := v.oldestEventTime + if endTime == 0 { + return nil + } + return func() tea.Msg { + return v.doFetchLogs(0, endTime, true) + } +} + +func (v *LogView) doFetchLogs(startTime, endTime int64, older bool) tea.Msg { + if err := v.ctx.Err(); err != nil { + return logsLoadedMsg{err: err, older: older} + } + if v.client == nil { + return logsLoadedMsg{ + err: apperrors.Wrap(fmt.Errorf("CloudWatch Logs client not initialized"), "fetch logs"), + older: older, + } + } + + ctx, cancel := context.WithTimeout(v.ctx, config.File().LogFetchTimeout()) + defer cancel() + + input := &cloudwatchlogs.FilterLogEventsInput{ + LogGroupName: appaws.StringPtr(v.logGroupName), + Limit: appaws.Int32Ptr(logFetchLimit), + } + + if v.logStreamName != "" { + input.LogStreamNames = []string{v.logStreamName} + } + + if older { + input.StartTime = appaws.Int64Ptr(endTime - time.Hour.Milliseconds()) + input.EndTime = appaws.Int64Ptr(endTime - 1) + } else if startTime > 0 { + input.StartTime = appaws.Int64Ptr(startTime + 1) + } else { + input.StartTime = appaws.Int64Ptr(time.Now().Add(-1 * time.Hour).UnixMilli()) + } + + output, err := v.client.FilterLogEvents(ctx, input) + if err != nil { + return v.handleFetchError(err, older) + } + + return v.processLogEvents(output.Events, older) +} + +func (v *LogView) handleFetchError(err error, older bool) logsLoadedMsg { + var wrappedErr error + throttled := apperrors.IsThrottling(err) + + switch { + case apperrors.IsNotFound(err): + if v.logStreamName != "" { + wrappedErr = apperrors.Wrap(err, "log stream not found") + } else { + wrappedErr = apperrors.Wrap(err, "log group not found") + } + case apperrors.IsAccessDenied(err): + wrappedErr = apperrors.Wrap(err, "access denied to CloudWatch Logs") + default: + wrappedErr = apperrors.Wrap(err, "filter log events") + } + + return logsLoadedMsg{err: wrappedErr, throttled: throttled, older: older} +} + +func (v *LogView) processLogEvents(events []types.FilteredLogEvent, older bool) logsLoadedMsg { + var boundaryTime int64 + entries := make([]logEntry, 0, len(events)) + + for _, event := range events { + ts := time.UnixMilli(appaws.Int64(event.Timestamp)) + msg := appaws.Str(event.Message) + entries = append(entries, logEntry{ + timestamp: ts, + message: strings.TrimSuffix(msg, "\n"), + }) + + eventTs := appaws.Int64(event.Timestamp) + if older { + if boundaryTime == 0 || eventTs < boundaryTime { + boundaryTime = eventTs + } + } else { + if eventTs > boundaryTime { + boundaryTime = eventTs + } + } + } + + return logsLoadedMsg{entries: entries, lastEventTime: boundaryTime, older: older} +} + +func (v *LogView) tickCmd() tea.Cmd { + return tea.Tick(v.pollInterval, func(t time.Time) tea.Msg { + return logTickMsg(t) + }) +} + +func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case logsLoadedMsg: + v.loading = false + if msg.err != nil { + log.Warn("failed to fetch log events", "error", msg.err) + v.err = msg.err + if msg.throttled { + v.pollInterval = min(v.pollInterval*2, maxLogPollInterval) + log.Info("throttled, backing off", "interval", v.pollInterval) + if !v.paused && !msg.older { + return v, v.tickCmd() + } + } + return v, nil + } + v.pollInterval = defaultLogPollInterval + v.err = nil + if msg.older { + if len(msg.entries) > 0 { + v.logs = append(msg.entries, v.logs...) + if len(v.logs) > maxLogBufferSize { + v.logs = v.logs[:maxLogBufferSize] + } + if msg.lastEventTime > 0 { + v.oldestEventTime = msg.lastEventTime + } + if v.vp.Ready { + v.updateViewportContent() + } + } + return v, nil + } + if msg.lastEventTime > v.lastEventTime { + v.lastEventTime = msg.lastEventTime + } + if len(msg.entries) > 0 { + if v.oldestEventTime == 0 && len(msg.entries) > 0 { + v.oldestEventTime = msg.entries[0].timestamp.UnixMilli() + } + v.logs = append(v.logs, msg.entries...) + if len(v.logs) > maxLogBufferSize { + v.logs = v.logs[len(v.logs)-maxLogBufferSize:] + } + if v.vp.Ready { + v.updateViewportContent() + v.vp.Model.GotoBottom() + } + } + if !v.paused { + return v, v.tickCmd() + } + return v, nil + + case logTickMsg: + if v.paused { + return v, nil + } + return v, v.fetchLogsCmd() + + case tea.KeyPressMsg: + switch msg.String() { + case "space": + v.paused = !v.paused + if !v.paused { + return v, v.tickCmd() + } + return v, nil + case "g": + if v.vp.Ready { + v.vp.Model.GotoTop() + } + return v, nil + case "G": + if v.vp.Ready { + v.vp.Model.GotoBottom() + } + return v, nil + case "c": + v.logs = v.logs[:0] + v.oldestEventTime = 0 + if v.vp.Ready { + v.updateViewportContent() + } + return v, nil + case "p": + if v.oldestEventTime > 0 && !v.loading { + v.loading = true + return v, v.fetchOlderLogsCmd() + } + return v, nil + } + + case spinner.TickMsg: + if v.loading { + var cmd tea.Cmd + v.spinner, cmd = v.spinner.Update(msg) + return v, cmd + } + } + + if v.vp.Ready { + var cmd tea.Cmd + v.vp.Model, cmd = v.vp.Model.Update(msg) + return v, cmd + } + return v, nil +} + +func (v *LogView) updateViewportContent() { + var sb strings.Builder + for _, entry := range v.logs { + ts := v.styles.timestamp.Render(entry.timestamp.Format("15:04:05.000")) + msg := v.styles.message.Render(entry.message) + sb.WriteString(fmt.Sprintf("%s %s\n", ts, msg)) + } + v.vp.Model.SetContent(sb.String()) +} + +func (v *LogView) ViewString() string { + if !v.vp.Ready { + return LoadingMessage + } + + var sb strings.Builder + + title := v.logGroupName + if v.logStreamName != "" { + title = fmt.Sprintf("%s / %s", v.logGroupName, v.logStreamName) + } + sb.WriteString(v.styles.header.Render("📜 " + title)) + sb.WriteString("\n") + + if v.paused { + sb.WriteString(v.styles.paused.Render("⏸ PAUSED")) + sb.WriteString(" ") + } + sb.WriteString(v.styles.dim.Render(fmt.Sprintf("(%d lines)", len(v.logs)))) + sb.WriteString("\n\n") + + if v.loading { + sb.WriteString(v.spinner.View()) + sb.WriteString(" Loading logs...") + return sb.String() + } + + if v.err != nil { + sb.WriteString(v.styles.error.Render(fmt.Sprintf("Error: %v", v.err))) + return sb.String() + } + + if len(v.logs) == 0 { + sb.WriteString(v.styles.dim.Render("No log events found in the last hour")) + return sb.String() + } + + sb.WriteString(v.vp.Model.View()) + return sb.String() +} + +func (v *LogView) View() tea.View { + return tea.NewView(v.ViewString()) +} + +func (v *LogView) SetSize(width, height int) tea.Cmd { + viewportHeight := height - viewportHeaderOffset + v.vp.SetSize(width, viewportHeight) + v.updateViewportContent() + return nil +} + +func (v *LogView) StatusLine() string { + status := "Space:pause/resume p:older g/G:top/bottom c:clear Esc:back" + if v.paused { + return "⏸ PAUSED • " + status + } + if v.pollInterval > defaultLogPollInterval { + return fmt.Sprintf("⏳ THROTTLED (%ds) • %s", int(v.pollInterval.Seconds()), status) + } + return "▶ STREAMING • " + status +} diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go new file mode 100644 index 00000000..02a048d4 --- /dev/null +++ b/internal/view/log_view_test.go @@ -0,0 +1,286 @@ +package view + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func TestNewLogView(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/lambda/my-function") + + if lv.logGroupName != "/aws/lambda/my-function" { + t.Errorf("logGroupName = %q, want %q", lv.logGroupName, "/aws/lambda/my-function") + } + if lv.logStreamName != "" { + t.Errorf("logStreamName = %q, want empty", lv.logStreamName) + } + if !lv.loading { + t.Error("Expected loading to be true initially") + } + if lv.paused { + t.Error("Expected paused to be false initially") + } + if lv.pollInterval != defaultLogPollInterval { + t.Errorf("pollInterval = %v, want %v", lv.pollInterval, defaultLogPollInterval) + } +} + +func TestNewLogViewWithStream(t *testing.T) { + ctx := context.Background() + lv := NewLogViewWithStream(ctx, "/aws/lambda/my-function", "2024/01/01/[$LATEST]abc123", 0) + + if lv.logGroupName != "/aws/lambda/my-function" { + t.Errorf("logGroupName = %q, want %q", lv.logGroupName, "/aws/lambda/my-function") + } + if lv.logStreamName != "2024/01/01/[$LATEST]abc123" { + t.Errorf("logStreamName = %q, want %q", lv.logStreamName, "2024/01/01/[$LATEST]abc123") + } +} + +func TestLogViewLogsLoadedSuccess(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + + entries := []logEntry{ + {timestamp: time.Now(), message: "log line 1"}, + {timestamp: time.Now(), message: "log line 2"}, + } + msg := logsLoadedMsg{entries: entries, lastEventTime: 1234567890} + + lv.Update(msg) + + if lv.loading { + t.Error("Expected loading to be false after logsLoadedMsg") + } + if len(lv.logs) != 2 { + t.Errorf("len(logs) = %d, want 2", len(lv.logs)) + } + if lv.lastEventTime != 1234567890 { + t.Errorf("lastEventTime = %d, want 1234567890", lv.lastEventTime) + } + if lv.err != nil { + t.Errorf("err = %v, want nil", lv.err) + } +} + +func TestLogViewLogsLoadedError(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + + msg := logsLoadedMsg{err: fmt.Errorf("access denied")} + + lv.Update(msg) + + if lv.loading { + t.Error("Expected loading to be false after error") + } + if lv.err == nil { + t.Error("Expected err to be set after error message") + } +} + +func TestLogViewBufferTrimming(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + for i := 0; i < 999; i++ { + lv.logs = append(lv.logs, logEntry{ + timestamp: time.Now(), + message: fmt.Sprintf("line %d", i), + }) + } + + newEntries := make([]logEntry, 10) + for i := 0; i < 10; i++ { + newEntries[i] = logEntry{timestamp: time.Now(), message: fmt.Sprintf("new line %d", i)} + } + msg := logsLoadedMsg{entries: newEntries, lastEventTime: 1} + + lv.Update(msg) + + if len(lv.logs) != 1000 { + t.Errorf("len(logs) = %d, want 1000 (buffer should trim to max)", len(lv.logs)) + } + + if !strings.Contains(lv.logs[0].message, "line 9") { + t.Errorf("first log = %q, expected oldest kept entry 'line 9'", lv.logs[0].message) + } +} + +func TestLogViewPauseToggle(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + if lv.paused { + t.Error("Expected paused to be false initially") + } + + spaceMsg := tea.KeyPressMsg{Code: tea.KeySpace} + lv.Update(spaceMsg) + + if !lv.paused { + t.Error("Expected paused to be true after first space") + } + + lv.Update(spaceMsg) + + if lv.paused { + t.Error("Expected paused to be false after second space") + } +} + +func TestLogViewClearLogs(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.logs = []logEntry{ + {timestamp: time.Now(), message: "line 1"}, + {timestamp: time.Now(), message: "line 2"}, + } + + cMsg := tea.KeyPressMsg{Code: 0, Text: "c"} + lv.Update(cMsg) + + if len(lv.logs) != 0 { + t.Errorf("len(logs) = %d, want 0 after clear", len(lv.logs)) + } +} + +func TestLogViewTickWhenPaused(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + lv.paused = true + + tickMsg := logTickMsg(time.Now()) + _, cmd := lv.Update(tickMsg) + + if cmd != nil { + t.Error("Expected nil cmd when paused (no fetch should be triggered)") + } +} + +func TestLogViewStatusLine(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + + streamingStatus := lv.StatusLine() + if !strings.Contains(streamingStatus, "STREAMING") { + t.Errorf("StatusLine() = %q, want to contain 'STREAMING'", streamingStatus) + } + + lv.paused = true + pausedStatus := lv.StatusLine() + if !strings.Contains(pausedStatus, "PAUSED") { + t.Errorf("StatusLine() = %q, want to contain 'PAUSED'", pausedStatus) + } +} + +func TestLogViewViewStringStates(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + setup func(*LogView) + wantContain string + }{ + { + name: "loading state", + setup: func(lv *LogView) { lv.loading = true }, + wantContain: "Loading", + }, + { + name: "error state", + setup: func(lv *LogView) { + lv.loading = false + lv.err = fmt.Errorf("test error") + }, + wantContain: "Error", + }, + { + name: "empty state", + setup: func(lv *LogView) { + lv.loading = false + }, + wantContain: "No log events", + }, + { + name: "paused state", + setup: func(lv *LogView) { + lv.loading = false + lv.paused = true + }, + wantContain: "PAUSED", + }, + { + name: "with stream name in title", + setup: func(lv *LogView) { + lv.loading = false + lv.logStreamName = "my-stream" + }, + wantContain: "my-stream", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + tt.setup(lv) + + view := lv.ViewString() + if !strings.Contains(view, tt.wantContain) { + t.Errorf("ViewString() = %q, want to contain %q", view, tt.wantContain) + } + }) + } +} + +func TestLogViewSetSize(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + + cmd := lv.SetSize(120, 40) + + if cmd != nil { + t.Error("Expected SetSize to return nil cmd") + } + if !lv.vp.Ready { + t.Error("Expected vp.Ready to be true after SetSize") + } +} + +func TestLogViewGotoTopBottom(t *testing.T) { + ctx := context.Background() + lv := NewLogView(ctx, "/aws/test") + lv.SetSize(80, 24) + lv.loading = false + + for i := 0; i < 50; i++ { + lv.logs = append(lv.logs, logEntry{ + timestamp: time.Now(), + message: fmt.Sprintf("line %d", i), + }) + } + lv.updateViewportContent() + + gMsg := tea.KeyPressMsg{Code: 0, Text: "g"} + lv.Update(gMsg) + + GMsg := tea.KeyPressMsg{Code: 0, Text: "G"} + lv.Update(GMsg) +} diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index b1236ae1..b764f5e7 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" @@ -25,13 +24,12 @@ 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), - itemChecked: lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Success), - filter: lipgloss.NewStyle().Foreground(t.Accent), + itemSelected: ui.SelectedStyle().PaddingLeft(2), + itemChecked: ui.SuccessStyle().PaddingLeft(2), + filter: ui.AccentStyle(), } } @@ -41,8 +39,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 +133,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 +225,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 +274,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 +324,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 +332,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 +351,11 @@ func (m *MultiSelector[T]) ViewString() string { filterView = m.styles.filter.Render("filter: "+m.filterText) + "\n" } - if !m.ready { - return title + "\n" + filterView + "Loading..." + if !m.vp.Ready { + return title + "\n" + filterView + LoadingMessage } - 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 +364,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/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 609fbad3..cc2d981a 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" @@ -37,14 +38,13 @@ type resourceBrowserStyles struct { } func newResourceBrowserStyles() resourceBrowserStyles { - t := ui.Current() return resourceBrowserStyles{ - count: lipgloss.NewStyle().Foreground(t.TextDim), - 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), - tabInactive: lipgloss.NewStyle().Foreground(t.TextDim).Padding(0, 1), + count: ui.DimStyle(), + filterBg: ui.InputFieldStyle(), + filterActive: ui.AccentStyle().Italic(true), + tabSingle: ui.PrimaryStyle(), + tabActive: ui.SelectedStyle().Padding(0, 1), + tabInactive: ui.DimStyle().Padding(0, 1), } } @@ -407,10 +407,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 +429,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 6047df62..30c44e29 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" @@ -40,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 @@ -50,8 +47,7 @@ type ServiceBrowser struct { headerPanel *HeaderPanel // Viewport for scrolling - viewport viewport.Model - ready bool + vp ViewportState // Filter filterInput textinput.Model @@ -76,7 +72,6 @@ type flatItem struct { } type serviceBrowserStyles struct { - title lipgloss.Style category lipgloss.Style cell lipgloss.Style cellSelected lipgloss.Style @@ -88,15 +83,8 @@ type serviceBrowserStyles struct { } func newServiceBrowserStyles() serviceBrowserStyles { - t := ui.Current() return serviceBrowserStyles{ - title: lipgloss.NewStyle(). - Background(t.TableHeader). - Foreground(t.TableHeaderText). - Padding(0, 1). - MarginBottom(1), - category: lipgloss.NewStyle(). - Foreground(t.TextDim). + category: ui.DimStyle(). Bold(true). MarginTop(1). MarginBottom(0), @@ -104,23 +92,15 @@ 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), - serviceName: lipgloss.NewStyle(). - Bold(true). - Foreground(t.Text), - serviceNameSe: lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary), - aliases: lipgloss.NewStyle(). - Foreground(t.TextDim), - aliasesSel: lipgloss.NewStyle(). - Foreground(t.TextDim), - filterPrompt: lipgloss.NewStyle(). - Foreground(t.Primary), + Padding(0, 1), + serviceName: ui.TextStyle().Bold(true), + serviceNameSe: ui.TitleStyle(), + aliases: ui.DimStyle(), + aliasesSel: ui.DimStyle(), + filterPrompt: ui.PrimaryStyle(), } } @@ -205,9 +185,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,42 +341,32 @@ 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 { - newOffset = totalLines - vpHeight - } - if newOffset < 0 { - newOffset = 0 - } - s.viewport.SetYOffset(newOffset) + newOffset = max(0, min(newOffset, totalLines-vpHeight)) + s.vp.Model.SetYOffset(newOffset) } } @@ -458,18 +427,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 +455,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 { - return header + "\n" + "Loading..." + if !s.vp.Ready { + return header + "\n" + LoadingMessage } - // 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 @@ -551,9 +514,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 @@ -564,7 +527,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{ @@ -622,41 +585,20 @@ 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) // 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) - // 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) - } + vpHeight := max(height-headerHeight+1, 5) - // 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/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/tag_search_view.go b/internal/view/tag_search_view.go index fba34a59..f50beafc 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" @@ -32,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 @@ -66,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(), @@ -555,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..." @@ -591,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 { @@ -683,11 +689,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 +706,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 +716,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 { diff --git a/internal/view/view.go b/internal/view/view.go index a488d082..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 @@ -138,6 +141,10 @@ func (h *NavigationHelper) HandleKey(key string, resource dao.Resource) tea.Cmd navigations := navigator.Navigations(resource) for _, nav := range navigations { if nav.Key == key { + if nav.ViewType != "" { + return h.createCustomView(nav, resource) + } + var newBrowser *ResourceBrowser if nav.AutoReload { interval := nav.ReloadInterval @@ -171,3 +178,41 @@ func (h *NavigationHelper) HandleKey(key string, resource dao.Resource) tea.Cmd return nil } + +func (h *NavigationHelper) createCustomView(nav render.Navigation, resource dao.Resource) tea.Cmd { + switch nav.ViewType { + case render.ViewTypeLogView: + return h.createLogView(resource) + default: + return nil + } +} + +func (h *NavigationHelper) createLogView(resource dao.Resource) tea.Cmd { + var logView *LogView + + type logGroupProvider interface{ LogGroupName() string } + type logStreamProvider interface{ LogStreamName() string } + type lastEventProvider interface{ LastEventTimestamp() int64 } + + unwrapped := dao.UnwrapResource(resource) + + if p, ok := unwrapped.(logGroupProvider); ok { + logGroupName := p.LogGroupName() + if sp, ok := unwrapped.(logStreamProvider); ok { + var lastEvent int64 + if lp, ok := unwrapped.(lastEventProvider); ok { + lastEvent = lp.LastEventTimestamp() + } + logView = NewLogViewWithStream(h.Ctx, logGroupName, sp.LogStreamName(), lastEvent) + } else { + logView = NewLogView(h.Ctx, logGroupName) + } + } else { + logView = NewLogView(h.Ctx, unwrapped.GetID()) + } + + return func() tea.Msg { + return NavigateMsg{View: logView} + } +} 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) } }) } 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) + } +}