5 Commits

Author SHA1 Message Date
x a32b7e4693 Use TokenCounter and correct unsafe pointer usage
Record write errors in TokenCounter.Printf and add Println method
to propagate write failures. Route file-read warnings to stderr
instead of mixing them into token output. Use unsafe.Add with a
base pointer when computing the drop target on Windows.
2026-01-31 03:33:36 +01:00
x 14dfacea29 Refine TokenCounter handling and output
Decode across Write calls and buffer partial runes. Approximate
tokenization by splitting long alphanumeric runs (>4) and properly count
spaces and punctuation. Remove TokenCounter Printf/Println helpers and
update callers to use fmt.Fprintf/Fprintln. Avoid writing when the
underlying writer is nil.
2026-01-31 03:24:17 +01:00
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
9 changed files with 312 additions and 27 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
}
+105
View File
@@ -0,0 +1,105 @@
//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")
}
basePtr := unsafe.Pointer(ptrVal)
df := (*dropFiles)(basePtr)
df.pFiles = dropSize
df.fWide = 1
targetPtr := unsafe.Add(basePtr, 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")
} }
+3 -3
View File
@@ -52,7 +52,7 @@ func writeOutput(root string, files []string, outputPath string) (count int64, e
fullPath := filepath.Join(root, file) fullPath := filepath.Join(root, file)
content, err := os.ReadFile(fullPath) content, err := os.ReadFile(fullPath)
if err != nil { if err != nil {
tc.Printf("Error reading %s: %v\n", file, err) fmt.Fprintf(os.Stderr, "ctx: warning: skipping %s: %v\n", file, err)
continue continue
} }
@@ -116,9 +116,9 @@ func printNode(w io.Writer, node map[string]any, prefix string) error {
children := node[key].(map[string]any) children := node[key].(map[string]any)
if len(children) > 0 { if len(children) > 0 {
childPrefix := prefix + "│ " childPrefix := prefix + "│   "
if isLast { if isLast {
childPrefix = prefix + " " childPrefix = prefix + "    "
} }
if err := printNode(w, children, childPrefix); err != nil { if err := printNode(w, children, childPrefix); err != nil {
return err return err
+51 -19
View File
@@ -4,14 +4,17 @@ import (
"fmt" "fmt"
"io" "io"
"unicode" "unicode"
"unicode/utf8"
) )
type TokenCounter struct { type TokenCounter struct {
w io.Writer w io.Writer
Count int64 Count int64
Err error Err error
inWord bool
inSpace bool leftover []byte
inWord bool
wordLen int
} }
func (tc *TokenCounter) Write(p []byte) (int, error) { func (tc *TokenCounter) Write(p []byte) (int, error) {
@@ -19,8 +22,29 @@ func (tc *TokenCounter) Write(p []byte) (int, error) {
return 0, tc.Err return 0, tc.Err
} }
for _, b := range p { data := p
r := rune(b) if len(tc.leftover) > 0 {
data = make([]byte, len(tc.leftover)+len(p))
copy(data, tc.leftover)
copy(data[len(tc.leftover):], p)
}
totalProcessed := 0
for len(data) > 0 {
r, size := utf8.DecodeRune(data)
if r == utf8.RuneError && size == 1 {
if len(data) < utf8.UTFMax {
tc.leftover = data
break
}
}
data = data[size:]
totalProcessed += size
tc.leftover = nil
isSpace := unicode.IsSpace(r) isSpace := unicode.IsSpace(r)
isAlpha := unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' isAlpha := unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_'
@@ -28,24 +52,32 @@ func (tc *TokenCounter) Write(p []byte) (int, error) {
if !tc.inWord { if !tc.inWord {
tc.Count++ tc.Count++
tc.inWord = true tc.inWord = true
tc.inSpace = false tc.wordLen = 1
} else {
tc.wordLen++
if tc.wordLen > 4 {
tc.Count++
tc.wordLen = 1
}
} }
} else if isSpace { } else if isSpace {
if !tc.inSpace { tc.inWord = false
tc.Count++ tc.wordLen = 0
tc.inSpace = true
tc.inWord = false
}
} else { } else {
tc.Count++ tc.Count++
tc.inWord = false tc.inWord = false
tc.inSpace = false tc.wordLen = 0
} }
} }
n, err := tc.w.Write(p) var n int
tc.Err = err if tc.w != nil {
return n, err n, tc.Err = tc.w.Write(p)
} else {
n = len(p)
}
return n, tc.Err
} }
func (tc *TokenCounter) WriteByte(c byte) error { func (tc *TokenCounter) WriteByte(c byte) error {
@@ -57,12 +89,12 @@ func (tc *TokenCounter) Printf(format string, a ...any) {
if tc.Err != nil { if tc.Err != nil {
return return
} }
_, _ = fmt.Fprintf(tc, format, a...) _, tc.Err = fmt.Fprintf(tc, format, a...)
} }
func (tc *TokenCounter) Println(a ...any) { func (tc *TokenCounter) Println(a ...any) {
if tc.Err != nil { if tc.Err != nil {
return return
} }
_, _ = fmt.Fprintln(tc, a...) _, tc.Err = fmt.Fprintln(tc, a...)
} }