From 6525b4936d8bcf1f9090a597f32c7dbfe58bf6b6 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 18:12:51 +0000 Subject: [PATCH 1/8] feat: copy resource ID/ARN to clipboard (y/Y keys) (#59) - Add clipboard package with OSC52 + tmux/screen passthrough - y: copy resource ID, Y: copy ARN - Works in list view and detail view - Flash message shows copy confirmation --- internal/app/app.go | 17 +++++++++ internal/clipboard/clipboard.go | 46 +++++++++++++++++++++++++ internal/clipboard/clipboard_test.go | 36 +++++++++++++++++++ internal/view/detail_view.go | 15 ++++++-- internal/view/resource_browser_input.go | 25 ++++++++++++++ internal/view/resource_browser_nav.go | 4 +-- 6 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 internal/clipboard/clipboard.go create mode 100644 internal/clipboard/clipboard_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 9b5a2e7b..c053f260 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,8 @@ import ( type clearErrorMsg struct{} +type clearFlashMsg struct{} + // awsContextReadyMsg is sent when AWS context initialization completes type awsContextReadyMsg struct { err error @@ -86,6 +89,8 @@ type App struct { modalStack []*view.Modal modalRenderer *view.ModalRenderer + clipboardFlash string + styles appStyles } @@ -273,6 +278,16 @@ 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 + return a, tea.Tick(2*time.Second, 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 +380,8 @@ 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 != "" { + 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 00000000..916fcbde --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,46 @@ +package clipboard + +import ( + "encoding/base64" + "os" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" +) + +type CopiedMsg struct { + Label string + Value string +} + +func Copy(label, value string) tea.Cmd { + return func() tea.Msg { + writeOSC52(value) + _ = clipboard.WriteAll(value) + return CopiedMsg{Label: label, Value: value} + } +} + +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 + } + _, _ = os.Stdout.WriteString(seq) +} + +func CopyID(id string) tea.Cmd { + return Copy("ID", id) +} + +func CopyARN(arn string) tea.Cmd { + return Copy("ARN", arn) +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 00000000..299d22ec --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,36 @@ +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.Error("Copy should return a non-nil command") + } +} + +func TestCopyID(t *testing.T) { + cmd := CopyID("i-1234567890abcdef0") + if cmd == nil { + t.Error("CopyID should return a non-nil command") + } +} + +func TestCopyARN(t *testing.T) { + cmd := CopyARN("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0") + if cmd == nil { + t.Error("CopyARN should return a non-nil command") + } +} diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 279dcde3..33edb443 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": + resource := dao.UnwrapResource(d.resource) + return d, clipboard.CopyID(resource.GetID()) + case "Y": + resource := dao.UnwrapResource(d.resource) + if arn := resource.GetARN(); arn != "" { + return d, clipboard.CopyARN(arn) + } } } @@ -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/resource_browser_input.go b/internal/view/resource_browser_input.go index df8895ac..2a7dfd30 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 @@ -274,3 +279,23 @@ func (r *ResourceBrowser) openDetailView() (tea.Model, tea.Cmd) { return NavigateMsg{View: detailView} } } + +func (r *ResourceBrowser) handleCopyID() (tea.Model, tea.Cmd) { + if len(r.filtered) == 0 || r.table.Cursor() >= len(r.filtered) { + return r, nil + } + resource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) + return r, clipboard.CopyID(resource.GetID()) +} + +func (r *ResourceBrowser) handleCopyARN() (tea.Model, tea.Cmd) { + if len(r.filtered) == 0 || r.table.Cursor() >= len(r.filtered) { + return r, nil + } + resource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) + arn := resource.GetARN() + if arn == "" { + return r, nil + } + return r, clipboard.CopyARN(arn) +} diff --git a/internal/view/resource_browser_nav.go b/internal/view/resource_browser_nav.go index 37678ad2..b2d2a8a1 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 } From 84fee249bc7aca48a148da9eb5ea78e7e7b06eb3 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 18:22:43 +0000 Subject: [PATCH 2/8] fix: add missing docs and feedback for clipboard feature - Add y/Y keybindings to help_view.go and README.md - Add NoARNMsg for user feedback when ARN unavailable - Add inline comment for CopiedMsg.Value field --- README.md | 2 ++ internal/app/app.go | 17 +++++++++++++++-- internal/clipboard/clipboard.go | 8 +++++++- internal/clipboard/clipboard_test.go | 11 +++++++++++ internal/view/detail_view.go | 1 + internal/view/help_view.go | 2 ++ internal/view/resource_browser_input.go | 2 +- 7 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fcebb408..4790a2cc 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 c053f260..4e830316 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -89,7 +89,8 @@ type App struct { modalStack []*view.Modal modalRenderer *view.ModalRenderer - clipboardFlash string + clipboardFlash string + clipboardWarning bool styles appStyles } @@ -280,6 +281,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case clipboard.CopiedMsg: a.clipboardFlash = "Copied " + msg.Label + a.clipboardWarning = false + return a, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return clearFlashMsg{} + }) + + case clipboard.NoARNMsg: + a.clipboardFlash = "No ARN available" + a.clipboardWarning = true return a, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { return clearFlashMsg{} }) @@ -381,7 +390,11 @@ func (a *App) View() tea.View { if a.err != nil { statusContent = ui.DangerStyle().Render("Error: " + a.err.Error()) } else if a.clipboardFlash != "" { - statusContent = ui.SuccessStyle().Render("✓ " + 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 index 916fcbde..3dbaa3cc 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -11,9 +11,11 @@ import ( type CopiedMsg struct { Label string - Value string + Value string // retained for future use (logging, undo) } +type NoARNMsg struct{} + func Copy(label, value string) tea.Cmd { return func() tea.Msg { writeOSC52(value) @@ -44,3 +46,7 @@ func CopyID(id string) tea.Cmd { func CopyARN(arn string) tea.Cmd { return Copy("ARN", 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 index 299d22ec..83081d75 100644 --- a/internal/clipboard/clipboard_test.go +++ b/internal/clipboard/clipboard_test.go @@ -34,3 +34,14 @@ func TestCopyARN(t *testing.T) { t.Error("CopyARN should return a non-nil command") } } + +func TestNoARN(t *testing.T) { + cmd := NoARN() + if cmd == nil { + t.Error("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 33edb443..56e76026 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -149,6 +149,7 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if arn := resource.GetARN(); arn != "" { return d, clipboard.CopyARN(arn) } + return d, clipboard.NoARN() } } diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 57cbd3cb..93681b89 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 2a7dfd30..06f4a986 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -295,7 +295,7 @@ func (r *ResourceBrowser) handleCopyARN() (tea.Model, tea.Cmd) { resource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) arn := resource.GetARN() if arn == "" { - return r, nil + return r, clipboard.NoARN() } return r, clipboard.CopyARN(arn) } From 8a9e41bc9307b20a7bbbeb75c88d4b264e93c0c9 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sat, 3 Jan 2026 18:30:27 +0000 Subject: [PATCH 3/8] refactor: consolidate UnwrapResource calls in clipboard handler --- internal/view/detail_view.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 56e76026..f513aa24 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -141,11 +141,11 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}} } } - case "y": - resource := dao.UnwrapResource(d.resource) - return d, clipboard.CopyID(resource.GetID()) - case "Y": + case "y", "Y": resource := dao.UnwrapResource(d.resource) + if msg.String() == "y" { + return d, clipboard.CopyID(resource.GetID()) + } if arn := resource.GetARN(); arn != "" { return d, clipboard.CopyARN(arn) } From d6d9ffc01658d46eba74bd9adc980e76e0e116e9 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 4 Jan 2026 00:35:24 +0000 Subject: [PATCH 4/8] fix: add cursor bounds check and unify handler pattern - Add cursor >= 0 check to prevent potential panic on negative cursor - Unify all handlers to Pattern A (positive check) - Affected: handleMark, handleEnter, handleAction, handleCopyID, handleCopyARN --- internal/view/resource_browser_input.go | 38 ++++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index 06f4a986..de02c2f6 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -113,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 { @@ -138,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 { @@ -155,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}} @@ -281,21 +284,22 @@ func (r *ResourceBrowser) openDetailView() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleCopyID() (tea.Model, tea.Cmd) { - if len(r.filtered) == 0 || r.table.Cursor() >= len(r.filtered) { - return r, nil + 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()) } - resource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) - return r, clipboard.CopyID(resource.GetID()) + return r, nil } func (r *ResourceBrowser) handleCopyARN() (tea.Model, tea.Cmd) { - if len(r.filtered) == 0 || r.table.Cursor() >= len(r.filtered) { - return r, nil - } - resource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) - arn := resource.GetARN() - if arn == "" { + 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, clipboard.CopyARN(arn) + return r, nil } From 5a2e0f6fcf00cfdd4f6b9efc11d56aff72937604 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 4 Jan 2026 00:43:31 +0000 Subject: [PATCH 5/8] refactor: unify y/Y switch cases in detail_view --- internal/view/detail_view.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index f513aa24..9605e084 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -141,11 +141,10 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return ShowModalMsg{Modal: &Modal{Content: actionMenu, Width: ModalWidthActionMenu}} } } - case "y", "Y": + case "y": + return d, clipboard.CopyID(dao.UnwrapResource(d.resource).GetID()) + case "Y": resource := dao.UnwrapResource(d.resource) - if msg.String() == "y" { - return d, clipboard.CopyID(resource.GetID()) - } if arn := resource.GetARN(); arn != "" { return d, clipboard.CopyARN(arn) } From 396ed7c7d01cd9c10e3a1fd99496d2c29a08dc59 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 4 Jan 2026 00:51:22 +0000 Subject: [PATCH 6/8] test: improve clipboard test coverage and add OSC52 debug logging - Add debug logging for OSC52 write failures - Verify command execution returns correct CopiedMsg - Test Label and Value fields in all copy functions --- internal/clipboard/clipboard.go | 6 +++- internal/clipboard/clipboard_test.go | 48 +++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 3dbaa3cc..d061e9be 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -7,6 +7,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" + + "github.com/clawscli/claws/internal/log" ) type CopiedMsg struct { @@ -36,7 +38,9 @@ func writeOSC52(s string) { } else { seq = osc52 } - _, _ = os.Stdout.WriteString(seq) + if _, err := os.Stdout.WriteString(seq); err != nil { + log.Debug("OSC52 clipboard write failed", "error", err) + } } func CopyID(id string) tea.Cmd { diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go index 83081d75..01e6aa56 100644 --- a/internal/clipboard/clipboard_test.go +++ b/internal/clipboard/clipboard_test.go @@ -17,29 +17,67 @@ func TestCopiedMsg(t *testing.T) { func TestCopy(t *testing.T) { cmd := Copy("TestLabel", "TestValue") if cmd == nil { - t.Error("Copy should return a non-nil command") + 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.Error("CopyID should return a non-nil command") + 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) { - cmd := CopyARN("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0") + arn := "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0" + cmd := CopyARN(arn) if cmd == nil { - t.Error("CopyARN should return a non-nil command") + 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.Error("NoARN should return a non-nil command") + t.Fatal("NoARN should return a non-nil command") } + msg := cmd() if _, ok := msg.(NoARNMsg); !ok { t.Errorf("expected NoARNMsg, got %T", msg) From 681a353f4d4091a51df94f52c2de60f3ab270847 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 4 Jan 2026 00:56:55 +0000 Subject: [PATCH 7/8] docs: add godoc comments and extract flash duration constant - Add package and function documentation to clipboard package - Add debug logging for native clipboard write failures - Extract flashDuration constant (2s) in app.go --- internal/app/app.go | 6 ++++-- internal/clipboard/clipboard.go | 20 +++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 4e830316..7144f269 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -24,6 +24,8 @@ type clearErrorMsg struct{} type clearFlashMsg struct{} +const flashDuration = 2 * time.Second + // awsContextReadyMsg is sent when AWS context initialization completes type awsContextReadyMsg struct { err error @@ -282,14 +284,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case clipboard.CopiedMsg: a.clipboardFlash = "Copied " + msg.Label a.clipboardWarning = false - return a, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + 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(2*time.Second, func(t time.Time) tea.Msg { + return a, tea.Tick(flashDuration, func(t time.Time) tea.Msg { return clearFlashMsg{} }) diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index d061e9be..e44c5537 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -1,3 +1,6 @@ +// 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 ( @@ -11,21 +14,29 @@ import ( "github.com/clawscli/claws/internal/log" ) +// CopiedMsg is sent when a value has been successfully copied to the clipboard. type CopiedMsg struct { - Label string - Value string // retained for future use (logging, undo) + 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) - _ = clipboard.WriteAll(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" @@ -43,14 +54,17 @@ func writeOSC52(s string) { } } +// 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{} } } From d8da6987e0ceccb5086d53c924686c00d93a6431 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Sun, 4 Jan 2026 01:02:21 +0000 Subject: [PATCH 8/8] test: add view handler tests for clipboard y/Y keys - Add TestResourceBrowserCopyID/ARN/ARNNoARN/EmptyList - Add TestDetailViewCopyID/ARN/ARNNoARN - Add arn field to mockResource for testing --- internal/view/detail_view_test.go | 44 +++++++++++++ internal/view/resource_browser_test.go | 89 ++++++++++++++++++++++++++ internal/view/view_test.go | 3 +- 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/internal/view/detail_view_test.go b/internal/view/detail_view_test.go index 80222a56..7f067dfa 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/resource_browser_test.go b/internal/view/resource_browser_test.go index 5730526c..dca03bc0 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 29d679fa..8f597762 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 }