mirror of
https://github.com/skidoodle/ctx.git
synced 2026-04-28 19:27:41 +02:00
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.
This commit is contained in:
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -4,7 +4,7 @@ project_name: ctx
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Cocoa
|
||||
#include <stdlib.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <file> 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user