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") }