diff --git a/README.md b/README.md index fcebb40..4790a2c 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/internal/app/app.go b/internal/app/app.go index 9b5a2e7..7144f26 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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" @@ -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 @@ -86,6 +91,9 @@ type App struct { modalStack []*view.Modal modalRenderer *view.ModalRenderer + clipboardFlash string + clipboardWarning bool + styles appStyles } @@ -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 { @@ -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() } diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..e44c553 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -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{} } +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 0000000..01e6aa5 --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -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) + } +} diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 279dcde..9605e08 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -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" @@ -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() } } @@ -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) } diff --git a/internal/view/detail_view_test.go b/internal/view/detail_view_test.go index 80222a5..7f067df 100644 --- a/internal/view/detail_view_test.go +++ b/internal/view/detail_view_test.go @@ -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") + } +} diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 57cbd3c..93681b8 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -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" diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index df8895a..de02c2f 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -5,6 +5,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/clawscli/claws/internal/action" + "github.com/clawscli/claws/internal/clipboard" "github.com/clawscli/claws/internal/dao" ) @@ -48,6 +49,10 @@ func (r *ResourceBrowser) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cm return r.handleNumberKey(msg.String()) case "N": return r.handleLoadNextPage() + case "y": + return r.handleCopyID() + case "Y": + return r.handleCopyARN() } return nil, nil @@ -108,8 +113,9 @@ func (r *ResourceBrowser) handleEsc() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleMark() (tea.Model, tea.Cmd) { - if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) { - resource := r.filtered[r.table.Cursor()] + cursor := r.table.Cursor() + if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { + resource := r.filtered[cursor] if r.markedResource != nil && r.markedResource.GetID() == resource.GetID() { r.markedResource = nil } else { @@ -133,8 +139,9 @@ func (r *ResourceBrowser) handleMetricsToggle() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleEnter() (tea.Model, tea.Cmd) { - if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) { - ctx, resource := r.contextForResource(r.filtered[r.table.Cursor()]) + cursor := r.table.Cursor() + if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { + ctx, resource := r.contextForResource(r.filtered[cursor]) if r.markedResource != nil && r.markedResource.GetID() != resource.GetID() { diffView := NewDiffView(ctx, dao.UnwrapResource(r.markedResource), resource, r.renderer, r.service, r.resourceType) return r, func() tea.Msg { @@ -150,9 +157,10 @@ func (r *ResourceBrowser) handleEnter() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleAction() (tea.Model, tea.Cmd) { - if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) { + cursor := r.table.Cursor() + if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { if actions := action.Global.Get(r.service, r.resourceType); len(actions) > 0 { - ctx, resource := r.contextForResource(r.filtered[r.table.Cursor()]) + ctx, resource := r.contextForResource(r.filtered[cursor]) actionMenu := NewActionMenu(ctx, resource, r.service, r.resourceType) return r, func() tea.Msg { return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}} @@ -274,3 +282,24 @@ func (r *ResourceBrowser) openDetailView() (tea.Model, tea.Cmd) { return NavigateMsg{View: detailView} } } + +func (r *ResourceBrowser) handleCopyID() (tea.Model, tea.Cmd) { + cursor := r.table.Cursor() + if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { + resource := dao.UnwrapResource(r.filtered[cursor]) + return r, clipboard.CopyID(resource.GetID()) + } + return r, nil +} + +func (r *ResourceBrowser) handleCopyARN() (tea.Model, tea.Cmd) { + cursor := r.table.Cursor() + if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { + resource := dao.UnwrapResource(r.filtered[cursor]) + if arn := resource.GetARN(); arn != "" { + return r, clipboard.CopyARN(arn) + } + return r, clipboard.NoARN() + } + return r, nil +} diff --git a/internal/view/resource_browser_nav.go b/internal/view/resource_browser_nav.go index 37678ad..b2d2a8a 100644 --- a/internal/view/resource_browser_nav.go +++ b/internal/view/resource_browser_nav.go @@ -113,7 +113,7 @@ func (r *ResourceBrowser) StatusLine() string { if hasActions { base += " a:actions" } - base += " m:mark" + metricsHint + base += " m:mark y:copy" + metricsHint if navInfo != "" { base += " " + navInfo } @@ -124,7 +124,7 @@ func (r *ResourceBrowser) StatusLine() string { if hasActions { base += " a:actions" } - base += " m:mark" + metricsHint + base += " m:mark y:copy" + metricsHint if navInfo != "" { base += " " + navInfo } diff --git a/internal/view/resource_browser_test.go b/internal/view/resource_browser_test.go index 5730526..dca03bc 100644 --- a/internal/view/resource_browser_test.go +++ b/internal/view/resource_browser_test.go @@ -605,3 +605,92 @@ func TestFetchParallelAllErrors(t *testing.T) { t.Errorf("got %d errors, want 2", len(result.errors)) } } + +func TestResourceBrowserCopyID(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetSize(100, 50) + browser.renderer = &mockRenderer{detail: "test"} + + browser.resources = []dao.Resource{ + &mockResource{id: "i-1234567890abcdef0", name: "instance-1", arn: "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"}, + } + browser.applyFilter() + browser.buildTable() + browser.table.SetCursor(0) + + _, cmd := browser.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 TestResourceBrowserCopyARN(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetSize(100, 50) + browser.renderer = &mockRenderer{detail: "test"} + + browser.resources = []dao.Resource{ + &mockResource{id: "i-1234567890abcdef0", name: "instance-1", arn: "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0"}, + } + browser.applyFilter() + browser.buildTable() + browser.table.SetCursor(0) + + _, cmd := browser.Update(tea.KeyPressMsg{Code: 'Y'}) + if cmd == nil { + t.Fatal("Expected cmd from 'Y' key press") + } +} + +func TestResourceBrowserCopyARNNoARN(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetSize(100, 50) + browser.renderer = &mockRenderer{detail: "test"} + + browser.resources = []dao.Resource{ + &mockResource{id: "resource-1", name: "no-arn-resource", arn: ""}, + } + browser.applyFilter() + browser.buildTable() + browser.table.SetCursor(0) + + _, cmd := browser.Update(tea.KeyPressMsg{Code: 'Y'}) + if cmd == nil { + t.Fatal("Expected cmd from 'Y' key press for NoARN") + } +} + +func TestResourceBrowserCopyEmptyList(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetSize(100, 50) + browser.resources = []dao.Resource{} + browser.applyFilter() + browser.buildTable() + + _, cmdY := browser.Update(tea.KeyPressMsg{Code: 'y'}) + if cmdY != nil { + t.Error("Expected nil cmd for 'y' on empty list") + } + + _, cmdShiftY := browser.Update(tea.KeyPressMsg{Code: 'Y'}) + if cmdShiftY != nil { + t.Error("Expected nil cmd for 'Y' on empty list") + } +} diff --git a/internal/view/view_test.go b/internal/view/view_test.go index 29d679f..8f59776 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -14,12 +14,13 @@ import ( type mockResource struct { id string name string + arn string tags map[string]string } func (m *mockResource) GetID() string { return m.id } func (m *mockResource) GetName() string { return m.name } -func (m *mockResource) GetARN() string { return "" } +func (m *mockResource) GetARN() string { return m.arn } func (m *mockResource) GetTags() map[string]string { return m.tags } func (m *mockResource) Raw() any { return nil }