3 Commits

Author SHA1 Message Date
x 4453856fd1 Use NSURL and writeObjects for pasteboard files
Create an NSURL from the path and write it to the general pasteboard
with writeObjects:. Add nil checks for the created NSURL and remove the
legacy NSFilenamesPboardType setPropertyList code.
2026-01-31 02:10:20 +01:00
x bd22ad5500 Add quick test and fix Windows clipboard check
Run `go test -v ./...` in the release workflow before GoReleaser.
In the Windows test job replace Get-Clipboard with
System.Windows.Forms::GetFileDropList(), check for "$PWD\ctx.txt" and
improve success/error output.
2026-01-31 02:00:13 +01:00
x af9e0a5372 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.
2026-01-31 01:53:12 +01:00
7 changed files with 256 additions and 5 deletions
+5 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs: jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -22,6 +22,10 @@ jobs:
with: with:
go-version: stable go-version: stable
- name: Quick Sanity Check
shell: bash
run: go test -v ./...
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
with: with:
+53
View File
@@ -0,0 +1,53 @@
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 .
Add-Type -AssemblyName System.Windows.Forms
$files = [System.Windows.Forms.Clipboard]::GetFileDropList()
if ($files.Contains("$PWD\ctx.txt")) {
Write-Host "Success: Clipboard contains ctx.txt"
exit 0
}
Write-Error "Clipboard did not contain ctx.txt. Found: $files"
exit 1
+1 -1
View File
@@ -4,7 +4,7 @@ project_name: ctx
builds: builds:
- env: - env:
- CGO_ENABLED=0 - CGO_ENABLED=1
goos: goos:
- linux - linux
- windows - windows
+46
View File
@@ -0,0 +1,46 @@
//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;
NSURL *url = [NSURL fileURLWithPath:strPath];
if (!url) return 0;
NSPasteboard *pb = [NSPasteboard generalPasteboard];
[pb clearContents];
return [pb writeObjects:@[url]] ? 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
}
+38
View File
@@ -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
}
+103
View File
@@ -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
}
+10 -3
View File
@@ -6,6 +6,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/skidoodle/ctx/clipboard"
) )
const defaultOutputFilename = "ctx.txt" const defaultOutputFilename = "ctx.txt"
@@ -21,6 +23,7 @@ func run() error {
flag.Usage = usage flag.Usage = usage
configFlag := flag.Bool("config", false, "open global ignore file for editing") configFlag := flag.Bool("config", false, "open global ignore file for editing")
outputFlag := flag.String("o", defaultOutputFilename, "output filename") outputFlag := flag.String("o", defaultOutputFilename, "output filename")
flag.Parse() flag.Parse()
if *configFlag { if *configFlag {
@@ -62,6 +65,12 @@ func run() error {
return fmt.Errorf("writing output: %w", err) 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) duration := time.Since(start).Round(time.Millisecond)
fmt.Printf("ctx: generated %s (%d files, ~%d tokens) in %v\n", fmt.Printf("ctx: generated %s (%d files, ~%d tokens) in %v\n",
*outputFlag, len(files), tokenCount, duration) *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, " -config Open global ignore file for editing\n")
fmt.Fprintf(os.Stderr, " -o <file> Output filename (default %q)\n", defaultOutputFilename) fmt.Fprintf(os.Stderr, " -o <file> Output filename (default %q)\n", defaultOutputFilename)
fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " ctx . # Generate context for current dir\n") fmt.Fprintf(os.Stderr, " ctx . # Generate and copy file\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")
} }