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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ claws -l debug.log
| `c` | Clear filter and mark |
| `N` | Load next page (pagination) |
| `M` | Toggle inline metrics (EC2, RDS, Lambda) |
| `y` | Copy resource ID to clipboard |
| `Y` | Copy resource ARN to clipboard |
| `Ctrl+r` | Refresh (including metrics) |
| `R` | Select AWS region(s) (multi-select supported) |
| `P` | Select AWS profile(s) (multi-select supported) |
Expand Down
32 changes: 32 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"charm.land/lipgloss/v2"

"github.com/clawscli/claws/internal/aws"
"github.com/clawscli/claws/internal/clipboard"
"github.com/clawscli/claws/internal/config"
"github.com/clawscli/claws/internal/log"
navmsg "github.com/clawscli/claws/internal/msg"
Expand All @@ -21,6 +22,10 @@ import (

type clearErrorMsg struct{}

type clearFlashMsg struct{}

const flashDuration = 2 * time.Second

// awsContextReadyMsg is sent when AWS context initialization completes
type awsContextReadyMsg struct {
err error
Expand Down Expand Up @@ -86,6 +91,9 @@ type App struct {
modalStack []*view.Modal
modalRenderer *view.ModalRenderer

clipboardFlash string
clipboardWarning bool

styles appStyles
}

Expand Down Expand Up @@ -273,6 +281,24 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.err = nil
return a, nil

case clipboard.CopiedMsg:
a.clipboardFlash = "Copied " + msg.Label
a.clipboardWarning = false
return a, tea.Tick(flashDuration, func(t time.Time) tea.Msg {
return clearFlashMsg{}
})

case clipboard.NoARNMsg:
a.clipboardFlash = "No ARN available"
a.clipboardWarning = true
return a, tea.Tick(flashDuration, func(t time.Time) tea.Msg {
return clearFlashMsg{}
})

case clearFlashMsg:
a.clipboardFlash = ""
return a, nil

case awsContextReadyMsg:
a.awsInitializing = false
if msg.err != nil {
Expand Down Expand Up @@ -365,6 +391,12 @@ func (a *App) View() tea.View {
var statusContent string
if a.err != nil {
statusContent = ui.DangerStyle().Render("Error: " + a.err.Error())
} else if a.clipboardFlash != "" {
if a.clipboardWarning {
statusContent = ui.WarningStyle().Render("⚠ " + a.clipboardFlash)
} else {
statusContent = ui.SuccessStyle().Render("✓ " + a.clipboardFlash)
}
} else if a.currentView != nil {
statusContent = a.currentView.StatusLine()
}
Expand Down
70 changes: 70 additions & 0 deletions internal/clipboard/clipboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Package clipboard provides clipboard functionality for copying resource IDs and ARNs.
// It supports both OSC52 terminal escape sequences (for SSH/tmux sessions) and native
// system clipboard via the atotto/clipboard library.
package clipboard

import (
"encoding/base64"
"os"
"strings"

tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"

"github.com/clawscli/claws/internal/log"
)

// CopiedMsg is sent when a value has been successfully copied to the clipboard.
type CopiedMsg struct {
Label string // "ID" or "ARN"
Value string // The copied value (retained for future use: logging, undo)
}

// NoARNMsg is sent when attempting to copy an ARN for a resource that has no ARN.
type NoARNMsg struct{}

// Copy copies the given value to the clipboard and returns a tea.Cmd that sends a CopiedMsg.
// It writes to both OSC52 (terminal clipboard) and native system clipboard for maximum compatibility.
func Copy(label, value string) tea.Cmd {
return func() tea.Msg {
writeOSC52(value)
if err := clipboard.WriteAll(value); err != nil {
log.Debug("native clipboard write failed", "error", err)
}
return CopiedMsg{Label: label, Value: value}
}
}

// writeOSC52 writes the value to the terminal clipboard using OSC52 escape sequences.
// It automatically detects and wraps sequences for tmux and screen terminal multiplexers.
func writeOSC52(s string) {
encoded := base64.StdEncoding.EncodeToString([]byte(s))
osc52 := "\x1b]52;c;" + encoded + "\x07"

var seq string
if os.Getenv("TMUX") != "" {
seq = "\x1bPtmux;\x1b" + osc52 + "\x1b\\"
} else if strings.HasPrefix(os.Getenv("TERM"), "screen") {
seq = "\x1bP" + osc52 + "\x1b\\"
} else {
seq = osc52
}
if _, err := os.Stdout.WriteString(seq); err != nil {
log.Debug("OSC52 clipboard write failed", "error", err)
}
}

// CopyID copies a resource ID to the clipboard.
func CopyID(id string) tea.Cmd {
return Copy("ID", id)
}

// CopyARN copies a resource ARN to the clipboard.
func CopyARN(arn string) tea.Cmd {
return Copy("ARN", arn)
}

// NoARN returns a tea.Cmd that sends a NoARNMsg, indicating the resource has no ARN.
func NoARN() tea.Cmd {
return func() tea.Msg { return NoARNMsg{} }
}
85 changes: 85 additions & 0 deletions internal/clipboard/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package clipboard

import (
"testing"
)

func TestCopiedMsg(t *testing.T) {
msg := CopiedMsg{Label: "ID", Value: "i-1234567890abcdef0"}
if msg.Label != "ID" {
t.Errorf("expected Label 'ID', got %q", msg.Label)
}
if msg.Value != "i-1234567890abcdef0" {
t.Errorf("expected Value 'i-1234567890abcdef0', got %q", msg.Value)
}
}

func TestCopy(t *testing.T) {
cmd := Copy("TestLabel", "TestValue")
if cmd == nil {
t.Fatal("Copy should return a non-nil command")
}

msg := cmd()
copiedMsg, ok := msg.(CopiedMsg)
if !ok {
t.Fatalf("expected CopiedMsg, got %T", msg)
}
if copiedMsg.Label != "TestLabel" {
t.Errorf("expected Label 'TestLabel', got %q", copiedMsg.Label)
}
if copiedMsg.Value != "TestValue" {
t.Errorf("expected Value 'TestValue', got %q", copiedMsg.Value)
}
}

func TestCopyID(t *testing.T) {
cmd := CopyID("i-1234567890abcdef0")
if cmd == nil {
t.Fatal("CopyID should return a non-nil command")
}

msg := cmd()
copiedMsg, ok := msg.(CopiedMsg)
if !ok {
t.Fatalf("expected CopiedMsg, got %T", msg)
}
if copiedMsg.Label != "ID" {
t.Errorf("expected Label 'ID', got %q", copiedMsg.Label)
}
if copiedMsg.Value != "i-1234567890abcdef0" {
t.Errorf("expected Value 'i-1234567890abcdef0', got %q", copiedMsg.Value)
}
}

func TestCopyARN(t *testing.T) {
arn := "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"
cmd := CopyARN(arn)
if cmd == nil {
t.Fatal("CopyARN should return a non-nil command")
}

msg := cmd()
copiedMsg, ok := msg.(CopiedMsg)
if !ok {
t.Fatalf("expected CopiedMsg, got %T", msg)
}
if copiedMsg.Label != "ARN" {
t.Errorf("expected Label 'ARN', got %q", copiedMsg.Label)
}
if copiedMsg.Value != arn {
t.Errorf("expected Value %q, got %q", arn, copiedMsg.Value)
}
}

func TestNoARN(t *testing.T) {
cmd := NoARN()
if cmd == nil {
t.Fatal("NoARN should return a non-nil command")
}

msg := cmd()
if _, ok := msg.(NoARNMsg); !ok {
t.Errorf("expected NoARNMsg, got %T", msg)
}
}
15 changes: 13 additions & 2 deletions internal/view/detail_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"charm.land/lipgloss/v2"

"github.com/clawscli/claws/internal/action"
"github.com/clawscli/claws/internal/clipboard"
"github.com/clawscli/claws/internal/dao"
"github.com/clawscli/claws/internal/log"
"github.com/clawscli/claws/internal/registry"
Expand Down Expand Up @@ -132,13 +133,22 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return model, cmd
}

if msg.String() == "a" {
switch msg.String() {
case "a":
if actions := action.Global.Get(d.service, d.resType); len(actions) > 0 {
actionMenu := NewActionMenu(d.ctx, dao.UnwrapResource(d.resource), d.service, d.resType)
return d, func() tea.Msg {
return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}}
}
}
case "y":
return d, clipboard.CopyID(dao.UnwrapResource(d.resource).GetID())
case "Y":
resource := dao.UnwrapResource(d.resource)
if arn := resource.GetARN(); arn != "" {
return d, clipboard.CopyARN(arn)
}
return d, clipboard.NoARN()
}
}

Expand Down Expand Up @@ -224,7 +234,8 @@ func (d *DetailView) StatusLine() string {
parts = append(parts, "a:actions")
}

// Add navigation shortcuts
parts = append(parts, "y:copy")

if navInfo := d.getNavigationShortcuts(); navInfo != "" {
parts = append(parts, navInfo)
}
Expand Down
44 changes: 44 additions & 0 deletions internal/view/detail_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,47 @@ func (m *mockDAO) Supports(op dao.Operation) bool {
}
return true
}

func TestDetailViewCopyID(t *testing.T) {
resource := &mockResource{id: "i-1234567890abcdef0", name: "test-instance", arn: "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"}
ctx := context.Background()

dv := NewDetailView(ctx, resource, nil, "ec2", "instances", nil, nil)
dv.SetSize(100, 50)

_, cmd := dv.Update(tea.KeyPressMsg{Code: 'y'})
if cmd == nil {
t.Fatal("Expected cmd from 'y' key press")
}

msg := cmd()
if msg == nil {
t.Fatal("Expected message from clipboard command")
}
}

func TestDetailViewCopyARN(t *testing.T) {
resource := &mockResource{id: "i-1234567890abcdef0", name: "test-instance", arn: "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"}
ctx := context.Background()

dv := NewDetailView(ctx, resource, nil, "ec2", "instances", nil, nil)
dv.SetSize(100, 50)

_, cmd := dv.Update(tea.KeyPressMsg{Code: 'Y'})
if cmd == nil {
t.Fatal("Expected cmd from 'Y' key press")
}
}

func TestDetailViewCopyARNNoARN(t *testing.T) {
resource := &mockResource{id: "resource-1", name: "no-arn-resource", arn: ""}
ctx := context.Background()

dv := NewDetailView(ctx, resource, nil, "test", "items", nil, nil)
dv.SetSize(100, 50)

_, cmd := dv.Update(tea.KeyPressMsg{Code: 'Y'})
if cmd == nil {
t.Fatal("Expected cmd from 'Y' key press for NoARN")
}
}
2 changes: 2 additions & 0 deletions internal/view/help_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ func (h *HelpView) renderContent() string {
out += s.key.Render("c") + s.desc.Render("Clear filter") + "\n"
out += s.key.Render("Ctrl+r") + s.desc.Render("Refresh resources") + "\n"
out += s.key.Render("a") + s.desc.Render("Show actions menu") + "\n"
out += s.key.Render("y") + s.desc.Render("Copy resource ID to clipboard") + "\n"
out += s.key.Render("Y") + s.desc.Render("Copy resource ARN to clipboard") + "\n"

// Filter Syntax
out += "\n" + s.section.Render("Filter Syntax") + "\n"
Expand Down
Loading