From af9e0a5372b4300f3f1b6c869887c0500a5e42ce Mon Sep 17 00:00:00 2001 From: skidoodle Date: Sat, 31 Jan 2026 01:53:12 +0100 Subject: [PATCH] Add cross-platform clipboard support Implement CopyFile for darwin, linux, and windows and call it from main to copy generated output to the clipboard. Enable CGO for builds, update goreleaser and release workflow to use macOS, and add a cross-platform test workflow that verifies the clipboard. --- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 52 +++++++++++++++++ .goreleaser.yaml | 2 +- clipboard/darwin.go | 43 ++++++++++++++ clipboard/linux.go | 38 ++++++++++++ clipboard/windows.go | 103 +++++++++++++++++++++++++++++++++ main.go | 13 ++++- 7 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 clipboard/darwin.go create mode 100644 clipboard/linux.go create mode 100644 clipboard/windows.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 81143f7..ffd5754 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,7 +10,7 @@ permissions: jobs: goreleaser: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..cbfc9f8 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,52 @@ +name: Test Build + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Enable CGO (Mac/Linux only) + if: matrix.os != 'windows-latest' + run: echo "CGO_ENABLED=1" >> $GITHUB_ENV + + - name: Build + run: go build -v . + + - name: Verify Clipboard (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update && sudo apt-get install -y xclip xvfb + + xvfb-run sh -c ' + ./ctx . + CLIP_CONTENT=$(xclip -selection clipboard -o -t text/uri-list) + echo "Clipboard contains: $CLIP_CONTENT" + echo "$CLIP_CONTENT" | grep "file://" || exit 1 + echo "$CLIP_CONTENT" | grep "ctx.txt" || exit 1 + ' + + - name: Verify Clipboard (Windows) + if: matrix.os == 'windows-latest' + run: | + ./ctx.exe . + $files = Get-Clipboard -Format FileDropList + if ($files) { + Write-Host "Clipboard contains: $files" + if ($files.Name -contains "ctx.txt") { exit 0 } + } + Write-Error "Clipboard did not contain ctx.txt" + exit 1 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f9bb9a4..b25ac84 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,7 +4,7 @@ project_name: ctx builds: - env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 goos: - linux - windows diff --git a/clipboard/darwin.go b/clipboard/darwin.go new file mode 100644 index 0000000..af7f71c --- /dev/null +++ b/clipboard/darwin.go @@ -0,0 +1,43 @@ +//go:build darwin + +package clipboard + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa +#include +#import + +int copyFileToPasteboard(char* path) { + @autoreleasepool { + NSString *strPath = [NSString stringWithUTF8String:path]; + if (!strPath) return 0; + + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + [pb clearContents]; + [pb declareTypes:@[NSFilenamesPboardType] owner:nil]; + return [pb setPropertyList:@[strPath] forType:NSFilenamesPboardType] ? 1 : 0; + } +} +*/ +import "C" +import ( + "errors" + "path/filepath" + "unsafe" +) + +func CopyFile(path string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + + cPath := C.CString(absPath) + defer C.free(unsafe.Pointer(cPath)) + + if success := C.copyFileToPasteboard(cPath); success == 0 { + return errors.New("failed to write to macOS pasteboard") + } + return nil +} diff --git a/clipboard/linux.go b/clipboard/linux.go new file mode 100644 index 0000000..9a254f7 --- /dev/null +++ b/clipboard/linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package clipboard + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +func CopyFile(path string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + + uri := fmt.Sprintf("file://%s", absPath) + + if isCommandAvailable("wl-copy") { + cmd := exec.Command("wl-copy", "--type", "text/uri-list") + cmd.Stdin = strings.NewReader(uri) + return cmd.Run() + } + + if isCommandAvailable("xclip") { + cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "text/uri-list") + cmd.Stdin = strings.NewReader(uri) + return cmd.Run() + } + + return fmt.Errorf("install 'wl-copy' or 'xclip'") +} + +func isCommandAvailable(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} diff --git a/clipboard/windows.go b/clipboard/windows.go new file mode 100644 index 0000000..c17766a --- /dev/null +++ b/clipboard/windows.go @@ -0,0 +1,103 @@ +//go:build windows + +package clipboard + +import ( + "fmt" + "path/filepath" + "syscall" + "time" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + openClipboard = user32.NewProc("OpenClipboard") + closeClipboard = user32.NewProc("CloseClipboard") + emptyClipboard = user32.NewProc("EmptyClipboard") + setClipboardData = user32.NewProc("SetClipboardData") + globalAlloc = kernel32.NewProc("GlobalAlloc") + globalLock = kernel32.NewProc("GlobalLock") + globalUnlock = kernel32.NewProc("GlobalUnlock") + globalFree = kernel32.NewProc("GlobalFree") +) + +const ( + cfHDrop = 15 + gHnd = 0x0042 +) + +type dropFiles struct { + pFiles uint32 + pt struct{ x, y int32 } + fNC int32 + fWide int32 +} + +func CopyFile(path string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + + pathUTF16, err := syscall.UTF16FromString(absPath) + if err != nil { + return err + } + pathUTF16 = append(pathUTF16, 0) + + dropSize := uint32(unsafe.Sizeof(dropFiles{})) + dataSize := uint32(len(pathUTF16) * 2) + totalSize := uintptr(dropSize + dataSize) + + hMem, _, err := globalAlloc.Call(gHnd, totalSize) + if hMem == 0 { + return fmt.Errorf("GlobalAlloc failed: %v", err) + } + + ptrVal, _, _ := globalLock.Call(hMem) + if ptrVal == 0 { + _, _, _ = globalFree.Call(hMem) + return fmt.Errorf("GlobalLock failed") + } + + df := (*dropFiles)(unsafe.Pointer(ptrVal)) + df.pFiles = dropSize + df.fWide = 1 + + targetPtr := unsafe.Pointer(ptrVal + uintptr(dropSize)) + + srcSlice := unsafe.Slice((*uint16)(unsafe.Pointer(&pathUTF16[0])), len(pathUTF16)) + dstSlice := unsafe.Slice((*uint16)(targetPtr), len(pathUTF16)) + copy(dstSlice, srcSlice) + + _, _, _ = globalUnlock.Call(hMem) + + var openSuccess bool + for range 10 { + ret, _, _ := openClipboard.Call(0) + if ret != 0 { + openSuccess = true + break + } + time.Sleep(20 * time.Millisecond) + } + + if !openSuccess { + _, _, _ = globalFree.Call(hMem) + return fmt.Errorf("clipboard locked") + } + defer func() { + _, _, _ = closeClipboard.Call() + }() + + _, _, _ = emptyClipboard.Call() + + if ret, _, err := setClipboardData.Call(uintptr(cfHDrop), hMem); ret == 0 { + _, _, _ = globalFree.Call(hMem) + return fmt.Errorf("SetClipboardData failed: %v", err) + } + + return nil +} diff --git a/main.go b/main.go index 2b81971..5702eef 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "time" + + "github.com/skidoodle/ctx/clipboard" ) const defaultOutputFilename = "ctx.txt" @@ -21,6 +23,7 @@ func run() error { flag.Usage = usage configFlag := flag.Bool("config", false, "open global ignore file for editing") outputFlag := flag.String("o", defaultOutputFilename, "output filename") + flag.Parse() if *configFlag { @@ -62,6 +65,12 @@ func run() error { return fmt.Errorf("writing output: %w", err) } + if err := clipboard.CopyFile(*outputFlag); err != nil { + fmt.Fprintf(os.Stderr, "ctx: warning: clipboard copy failed: %v\n", err) + } else { + fmt.Printf("ctx: copied %s to clipboard\n", *outputFlag) + } + duration := time.Since(start).Round(time.Millisecond) fmt.Printf("ctx: generated %s (%d files, ~%d tokens) in %v\n", *outputFlag, len(files), tokenCount, duration) @@ -75,7 +84,5 @@ func usage() { fmt.Fprintf(os.Stderr, " -config Open global ignore file for editing\n") fmt.Fprintf(os.Stderr, " -o Output filename (default %q)\n", defaultOutputFilename) fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " ctx . # Generate context for current dir\n") - fmt.Fprintf(os.Stderr, " ctx -o out.txt src/ # Scan src/ and save to out.txt\n") - fmt.Fprintf(os.Stderr, " ctx -config # Open global ignore file\n") + fmt.Fprintf(os.Stderr, " ctx . # Generate and copy file\n") }