Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Notable changes to clippy.

## [Unreleased]

## [1.6.3] - 2026-01-10

### Added

- Pasty uses Finder-style duplicate naming (photo.png → photo 2.png) instead of overwriting
- `--force` / `-f` flag to override and allow overwriting
- Interactive picker auto-refreshes when new files appear in Downloads/Desktop
- Visual highlighting for newly appeared files in picker

### Fixed

- Multi-part extensions now handled correctly (archive.tar.gz → archive 2.tar.gz)

## [1.6.2] - 2026-01-06

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,11 @@ Also handles rich text with embedded images (`.rtfd` bundles from TextEdit/Notes
```bash
pasty --inspect # Show what's on clipboard and what pasty will use
pasty --plain notes.txt # Force plain text, strip all formatting
pasty -f existing.txt # Overwrite existing files instead of creating duplicates
```

By default, pasty uses Finder-style duplicate naming if a file already exists.

---

## Draggy - Visual Clipboard Companion
Expand Down
93 changes: 76 additions & 17 deletions clippy.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ func PasteToStdout() (*PasteResult, error) {
type PasteOptions struct {
PreserveFormat bool // If true, skip image format conversions (e.g., TIFF to PNG)
PlainTextOnly bool // If true, force plain text extraction (strip all formatting)
Force bool // If true, overwrite existing files instead of using Finder-style duplicate naming
}

// PasteToFile pastes clipboard content to a file or directory
Expand All @@ -503,7 +504,7 @@ func PasteToFile(destination string) (*PasteResult, error) {
func PasteToFileWithOptions(destination string, opts PasteOptions) (*PasteResult, error) {
// Priority 1: File references
if files := GetFiles(); len(files) > 0 {
return pasteFileReferences(files, destination)
return pasteFileReferences(files, destination, opts)
}

// Priority 2: Image/rich content data (skip if plain text only)
Expand All @@ -515,15 +516,15 @@ func PasteToFileWithOptions(destination string, opts PasteOptions) (*PasteResult

// Priority 3: Text content
if text, ok := GetText(); ok {
return pasteTextContent(text, destination)
return pasteTextContent(text, destination, opts)
}

return nil, fmt.Errorf("no content found on clipboard")
}

// pasteFileReferences copies file references from clipboard to destination
func pasteFileReferences(files []string, destination string) (*PasteResult, error) {
filesRead, err := copyFilesToDestination(files, destination)
func pasteFileReferences(files []string, destination string, opts PasteOptions) (*PasteResult, error) {
filesRead, err := copyFilesToDestination(files, destination, opts.Force)
if err != nil {
return nil, err
}
Expand All @@ -538,7 +539,7 @@ func pasteFileReferences(files []string, destination string) (*PasteResult, erro
func pasteImageData(content *clipboard.ClipboardContent, destination string, opts PasteOptions) (*PasteResult, error) {
// Special handling for RTFD (rich text with embedded images)
if content.Type == "com.apple.flat-rtfd" {
return pasteRTFDData(content, destination)
return pasteRTFDData(content, destination, opts)
}

ext := getFileExtensionFromUTI(content.Type)
Expand Down Expand Up @@ -569,7 +570,7 @@ func pasteImageData(content *clipboard.ClipboardContent, destination string, opt

defaultFilename := fmt.Sprintf("clipboard-%s%s", time.Now().Format("2006-01-02-150405"), ext)

destPath := resolveDestinationPath(destination, defaultFilename, true)
destPath := resolveDestinationPath(destination, defaultFilename, true, opts.Force)

if err := os.WriteFile(destPath, data, 0644); err != nil {
return nil, fmt.Errorf("could not write to file %s: %w", destPath, err)
Expand All @@ -582,9 +583,9 @@ func pasteImageData(content *clipboard.ClipboardContent, destination string, opt
}

// pasteRTFDData saves RTFD (rich text with embedded images) to .rtfd bundle
func pasteRTFDData(content *clipboard.ClipboardContent, destination string) (*PasteResult, error) {
func pasteRTFDData(content *clipboard.ClipboardContent, destination string, opts PasteOptions) (*PasteResult, error) {
defaultFilename := fmt.Sprintf("clipboard-%s.rtfd", time.Now().Format("2006-01-02-150405"))
destPath := resolveDestinationPath(destination, defaultFilename, true)
destPath := resolveDestinationPath(destination, defaultFilename, true, opts.Force)

// Ensure the path ends with .rtfd
if !strings.HasSuffix(destPath, ".rtfd") {
Expand All @@ -603,9 +604,9 @@ func pasteRTFDData(content *clipboard.ClipboardContent, destination string) (*Pa
}

// pasteTextContent saves text content from clipboard to file
func pasteTextContent(text string, destination string) (*PasteResult, error) {
func pasteTextContent(text string, destination string, opts PasteOptions) (*PasteResult, error) {
defaultFilename := fmt.Sprintf("clipboard-%s.txt", time.Now().Format("2006-01-02-150405"))
destPath := resolveDestinationPath(destination, defaultFilename, false)
destPath := resolveDestinationPath(destination, defaultFilename, false, opts.Force)

if err := os.WriteFile(destPath, []byte(text), 0644); err != nil {
return nil, fmt.Errorf("could not write to file %s: %w", destPath, err)
Expand All @@ -618,33 +619,89 @@ func pasteTextContent(text string, destination string) (*PasteResult, error) {
}, nil
}

// splitExtension splits a filename into base and extension, handling multi-part extensions.
// Examples: "file.tar.gz" → ("file", ".tar.gz"), "photo.png" → ("photo", ".png")
func splitExtension(filename string) (base string, ext string) {
multipartExts := []string{".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst"}

lower := strings.ToLower(filename)
for _, me := range multipartExts {
if strings.HasSuffix(lower, me) {
return filename[:len(filename)-len(me)], filename[len(filename)-len(me):]
}
}

ext = filepath.Ext(filename)
if ext != "" {
return filename[:len(filename)-len(ext)], ext
}
return filename, ""
}

// findAvailableFilename returns a filename that doesn't exist, using Finder's naming convention.
// If the file exists, tries "basename 2.ext", "basename 3.ext", etc.
// Format follows macOS Finder: "photo.png" → "photo 2.png" → "photo 3.png"
// If force is true, returns the path as-is (allows overwriting).
func findAvailableFilename(path string, force bool) string {
if force {
return path
}

if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}

dir := filepath.Dir(path)
base := filepath.Base(path)
nameWithoutExt, ext := splitExtension(base)

for i := 2; i < 10000; i++ {
var candidate string
if ext != "" {
candidate = filepath.Join(dir, fmt.Sprintf("%s %d%s", nameWithoutExt, i, ext))
} else {
candidate = filepath.Join(dir, fmt.Sprintf("%s %d", nameWithoutExt, i))
}

if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}

return path
}

// resolveDestinationPath determines the final file path for pasting content
// If destination is a directory or looks like one, joins it with defaultFilename
// If allowNoExtension is true, treats paths without extensions as directories
func resolveDestinationPath(destination string, defaultFilename string, allowNoExtension bool) string {
// Uses Finder-style duplicate naming if file exists (unless force is true).
func resolveDestinationPath(destination string, defaultFilename string, allowNoExtension bool, force bool) string {
destInfo, err := os.Stat(destination)

// Existing directory
if err == nil && destInfo.IsDir() {
return filepath.Join(destination, defaultFilename)
path := filepath.Join(destination, defaultFilename)
return findAvailableFilename(path, force)
}

// Path ends with /
if strings.HasSuffix(destination, "/") {
return filepath.Join(destination, defaultFilename)
path := filepath.Join(destination, defaultFilename)
return findAvailableFilename(path, force)
}

// Path doesn't exist and has no extension (for image data)
if allowNoExtension && err != nil && !strings.Contains(filepath.Base(destination), ".") {
return filepath.Join(destination, defaultFilename)
path := filepath.Join(destination, defaultFilename)
return findAvailableFilename(path, force)
}

// Use destination as-is (it's a file path)
return destination
// Use destination as-is (it's a file path) - check for duplicates
return findAvailableFilename(destination, force)
}

// copyFilesToDestination copies files from clipboard to destination
func copyFilesToDestination(files []string, destination string) (int, error) {
func copyFilesToDestination(files []string, destination string, force bool) (int, error) {
if len(files) == 0 {
return 0, fmt.Errorf("no files to copy")
}
Expand Down Expand Up @@ -676,6 +733,8 @@ func copyFilesToDestination(files []string, destination string) (int, error) {
destFile = destination
}

destFile = findAvailableFilename(destFile, force)

if err := recent.CopyFile(srcFile, destFile); err != nil {
return filesRead, fmt.Errorf("could not copy %s to %s: %w", srcFile, destFile, err)
}
Expand Down
102 changes: 102 additions & 0 deletions clippy_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package clippy

import (
"os"
"strings"
"testing"

Expand Down Expand Up @@ -182,4 +183,105 @@ func TestConvertImageFormat(t *testing.T) {
if err == nil {
t.Error("Expected error for unsupported format")
}
}

func TestFindAvailableFilename(t *testing.T) {
tmpDir := t.TempDir()

tests := []struct {
name string
existingFiles []string
inputPath string
want string
}{
{
name: "no conflict",
existingFiles: []string{},
inputPath: tmpDir + "/photo.png",
want: tmpDir + "/photo.png",
},
{
name: "one conflict with extension",
existingFiles: []string{"photo.png"},
inputPath: tmpDir + "/photo.png",
want: tmpDir + "/photo 2.png",
},
{
name: "two conflicts with extension",
existingFiles: []string{"photo.png", "photo 2.png"},
inputPath: tmpDir + "/photo.png",
want: tmpDir + "/photo 3.png",
},
{
name: "no conflict without extension",
existingFiles: []string{},
inputPath: tmpDir + "/README",
want: tmpDir + "/README",
},
{
name: "one conflict without extension",
existingFiles: []string{"README"},
inputPath: tmpDir + "/README",
want: tmpDir + "/README 2",
},
{
name: "multiple conflicts without extension",
existingFiles: []string{"README", "README 2", "README 3"},
inputPath: tmpDir + "/README",
want: tmpDir + "/README 4",
},
{
name: "multi-part extension",
existingFiles: []string{"archive.tar.gz"},
inputPath: tmpDir + "/archive.tar.gz",
want: tmpDir + "/archive 2.tar.gz",
},
{
name: "multi-part extension multiple conflicts",
existingFiles: []string{"backup.tar.bz2", "backup 2.tar.bz2"},
inputPath: tmpDir + "/backup.tar.bz2",
want: tmpDir + "/backup 3.tar.bz2",
},
{
name: "gaps in numbering",
existingFiles: []string{"file.txt", "file 3.txt"},
inputPath: tmpDir + "/file.txt",
want: tmpDir + "/file 2.txt",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, f := range tt.existingFiles {
if err := os.WriteFile(tmpDir+"/"+f, []byte("test"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
}

got := findAvailableFilename(tt.inputPath, false)
if got != tt.want {
t.Errorf("findAvailableFilename(%q, false)\n got: %q\n want: %q", tt.inputPath, got, tt.want)
}

for _, f := range tt.existingFiles {
_ = os.Remove(tmpDir + "/" + f)
}
})
}
}

func TestFindAvailableFilenameWithForce(t *testing.T) {
tmpDir := t.TempDir()

if err := os.WriteFile(tmpDir+"/existing.txt", []byte("test"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}

path := tmpDir + "/existing.txt"
got := findAvailableFilename(path, true)
want := path

if got != want {
t.Errorf("findAvailableFilename(%q, true) should return original path when force=true\n got: %q\n want: %q", path, got, want)
}
}
33 changes: 31 additions & 2 deletions cmd/clippy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,13 @@ func handleRecentMode(timeStr string, interactiveMode bool) {
// If interactive mode is requested, show the picker
if interactiveMode {
logger.Debug("Showing bubble tea picker with %d files", len(files))
result, err := showBubbleTeaPickerWithResult(files, config.AbsoluteTime)

// Create refresh function that re-scans directories
refreshFunc := func() ([]recent.FileInfo, error) {
return getRecentDownloadsWithDirs(config, maxFiles, searchDirs)
}

result, err := showBubbleTeaPickerWithResult(files, config.AbsoluteTime, refreshFunc, searchDirs)
if err != nil {
if err.Error() == "cancelled" {
fmt.Println("Cancelled.")
Expand Down Expand Up @@ -421,7 +427,30 @@ func handleFindMode(query string) {
}

// Show picker with results
pickerResult, err := showBubbleTeaPickerWithResult(files, absoluteTime)
// Create refresh function that re-runs the spotlight search
refreshFunc := func() ([]recent.FileInfo, error) {
newResults, err := spotlight.SearchWithMetadata(spotlight.SearchOptions{
Query: query,
MaxResults: 1000,
})
if err != nil {
return files, err
}
var newFiles []recent.FileInfo
for _, r := range newResults {
newFiles = append(newFiles, recent.FileInfo{
Path: r.Path,
Name: r.Name,
Size: r.Size,
Modified: r.Modified,
IsDir: r.IsDir,
})
}
return newFiles, nil
}

// Spotlight doesn't watch specific directories, pass nil for watchDirs
pickerResult, err := showBubbleTeaPickerWithResult(files, absoluteTime, refreshFunc, nil)
if err != nil {
logger.Error("Picker error: %v", err)
os.Exit(1)
Expand Down
Loading
Loading