From 44aaca5f5faca2701f1777c47c9243f0c03e34f2 Mon Sep 17 00:00:00 2001 From: Baptiste Canton Date: Fri, 14 Nov 2025 18:38:02 +0100 Subject: [PATCH] add cb:// opener --- pkg/openers/clipboard.go | 75 +++++++++++++++++++++++++ pkg/openers/clipboard_test.go | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 pkg/openers/clipboard.go create mode 100644 pkg/openers/clipboard_test.go diff --git a/pkg/openers/clipboard.go b/pkg/openers/clipboard.go new file mode 100644 index 000000000..33f3a542b --- /dev/null +++ b/pkg/openers/clipboard.go @@ -0,0 +1,75 @@ +//go:build !fileonly +// +build !fileonly + +package openers + +import ( + "fmt" + "io" + "strings" + + "github.com/atotto/clipboard" + "github.com/batmac/ccat/pkg/log" + "github.com/batmac/ccat/pkg/term" + "github.com/batmac/ccat/pkg/utils" +) + +var ( + clipboardOpenerName = "clipboard" + clipboardOpenerDescription = "get content from the system clipboard via cb://" +) + +type clipboardOpener struct { + name, description string +} + +func init() { + register(&clipboardOpener{ + name: clipboardOpenerName, + description: clipboardOpenerDescription, + }) +} + +func (c clipboardOpener) Name() string { + return c.name +} + +func (c clipboardOpener) Description() string { + return c.description +} + +func (c clipboardOpener) Open(s string, _ bool) (io.ReadCloser, error) { + // Verify protocol + protocol, _, found := strings.Cut(s, "://") + if !found || protocol != "cb" { + return nil, fmt.Errorf("clipboard opener requires cb:// protocol") + } + + // Warn if in SSH or container - reading from remote clipboard + if term.IsSSH() { + log.Println("WARNING: cb:// reads from remote server clipboard in SSH session") + log.Println(" Consider using stdin or clipboard forwarding for local clipboard access") + } else if utils.IsRunningInContainer() { + log.Println("WARNING: cb:// reads from container clipboard, not host clipboard") + } + + log.Debugln("Reading from clipboard...") + + // Read from clipboard + content, err := clipboard.ReadAll() + if err != nil { + return nil, fmt.Errorf("failed to read from clipboard: %w", err) + } + + log.Debugf("Read %d bytes from clipboard\n", len(content)) + + // Return content as a ReadCloser + return io.NopCloser(strings.NewReader(content)), nil +} + +func (c clipboardOpener) Evaluate(s string) float32 { + if strings.HasPrefix(s, "cb://") { + return 1.0 // High priority for cb:// URLs + } + return 0 +} diff --git a/pkg/openers/clipboard_test.go b/pkg/openers/clipboard_test.go new file mode 100644 index 000000000..125432f52 --- /dev/null +++ b/pkg/openers/clipboard_test.go @@ -0,0 +1,100 @@ +//go:build !fileonly +// +build !fileonly + +package openers + +import ( + "io" + "strings" + "testing" + + "github.com/atotto/clipboard" +) + +func TestClipboardOpener_Evaluate(t *testing.T) { + opener := &clipboardOpener{ + name: clipboardOpenerName, + description: clipboardOpenerDescription, + } + + tests := []struct { + name string + input string + expected float32 + }{ + {"valid cb URL", "cb://", 1.0}, + {"valid cb URL with content", "cb://anything", 1.0}, + {"http URL", "http://example.com", 0}, + {"file path", "/path/to/file", 0}, + {"echo URL", "echo://test", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := opener.Evaluate(tt.input) + if score != tt.expected { + t.Errorf("Evaluate(%q) = %v, want %v", tt.input, score, tt.expected) + } + }) + } +} + +func TestClipboardOpener_Open(t *testing.T) { + opener := &clipboardOpener{ + name: clipboardOpenerName, + description: clipboardOpenerDescription, + } + + // Test 1: Write to clipboard and read it back + testContent := "test clipboard content" + err := clipboard.WriteAll(testContent) + if err != nil { + t.Skipf("Clipboard not available (might be in CI): %v", err) + } + + rc, err := opener.Open("cb://", false) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer rc.Close() + + content, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + + if string(content) != testContent { + t.Errorf("Open() content = %q, want %q", string(content), testContent) + } + + // Test 2: Invalid protocol + _, err = opener.Open("http://example.com", false) + if err == nil { + t.Error("Open() with invalid protocol should return error") + } + if !strings.Contains(err.Error(), "cb://") { + t.Errorf("Open() error should mention cb:// protocol, got: %v", err) + } +} + +func TestClipboardOpener_Name(t *testing.T) { + opener := &clipboardOpener{ + name: clipboardOpenerName, + description: clipboardOpenerDescription, + } + + if name := opener.Name(); name != clipboardOpenerName { + t.Errorf("Name() = %q, want %q", name, clipboardOpenerName) + } +} + +func TestClipboardOpener_Description(t *testing.T) { + opener := &clipboardOpener{ + name: clipboardOpenerName, + description: clipboardOpenerDescription, + } + + if desc := opener.Description(); desc != clipboardOpenerDescription { + t.Errorf("Description() = %q, want %q", desc, clipboardOpenerDescription) + } +}