diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a399d6..3ce8c771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1df35389..f8a293ac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/clippy.go b/clippy.go index 4247840a..8924c512 100644 --- a/clippy.go +++ b/clippy.go @@ -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 @@ -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) @@ -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 } @@ -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) @@ -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) @@ -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") { @@ -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) @@ -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") } @@ -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) } diff --git a/clippy_test.go b/clippy_test.go index bb70e10f..fb72bcae 100644 --- a/clippy_test.go +++ b/clippy_test.go @@ -1,6 +1,7 @@ package clippy import ( + "os" "strings" "testing" @@ -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) + } } \ No newline at end of file diff --git a/cmd/clippy/main.go b/cmd/clippy/main.go index aa87cfcb..d0b5fc13 100644 --- a/cmd/clippy/main.go +++ b/cmd/clippy/main.go @@ -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.") @@ -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) diff --git a/cmd/clippy/picker_bubbletea.go b/cmd/clippy/picker_bubbletea.go index eeaebfa5..02bc753c 100644 --- a/cmd/clippy/picker_bubbletea.go +++ b/cmd/clippy/picker_bubbletea.go @@ -7,10 +7,19 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/fsnotify/fsnotify" "github.com/neilberkman/clippy/pkg/recent" "github.com/neilberkman/mimedescription" ) +// refreshMsg is sent when files should be refreshed +type refreshMsg struct { + files []recent.FileInfo +} + +// tickMsg is sent periodically to clean up old highlights +type tickMsg time.Time + // pickerModel represents the state of our file picker type pickerModel struct { files []recent.FileInfo @@ -22,6 +31,10 @@ type pickerModel struct { absoluteTime bool terminalWidth int terminalHeight int + refreshFunc func() ([]recent.FileInfo, error) // Function to call to refresh file list + watcher *fsnotify.Watcher // File system watcher for auto-refresh + watchDirs []string // Directories being watched + newFiles map[string]time.Time // Files that appeared recently (path -> time appeared) } // pickerItem represents a file item with its display state @@ -32,15 +45,121 @@ type pickerItem struct { focused bool } +// waitForFSEvent returns a command that waits for file system events +func (m pickerModel) waitForFSEvent() tea.Msg { + if m.watcher == nil { + return nil + } + + select { + case event, ok := <-m.watcher.Events: + if !ok { + return nil + } + // Only refresh on Create events (new files) + if event.Op&fsnotify.Create == fsnotify.Create { + if m.refreshFunc != nil { + files, err := m.refreshFunc() + if err == nil { + return refreshMsg{files: files} + } + } + } + case <-m.watcher.Errors: + // Ignore errors, just keep watching + } + return nil +} + // Initialize the model func (m pickerModel) Init() tea.Cmd { - // Request initial window size - return tea.WindowSize() + // Start watching for file system events if we have a watcher + if m.watcher != nil { + return tea.Batch( + func() tea.Msg { + return m.waitForFSEvent() + }, + tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }), + ) + } + return nil } // Update handles messages func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case refreshMsg: + // Preserve cursor by file name, not by index + // This prevents accidentally selecting a new file that appears at cursor position + var cursorFileName string + if m.cursor >= 0 && m.cursor < len(m.files) { + cursorFileName = m.files[m.cursor].Name + } + + // Build a set of existing file paths before update + existingFiles := make(map[string]bool) + for _, f := range m.files { + existingFiles[f.Path] = true + } + + // Update files list + m.files = msg.files + + // Mark new files that weren't in the previous list + if m.newFiles == nil { + m.newFiles = make(map[string]time.Time) + } + now := time.Now() + for _, file := range m.files { + if !existingFiles[file.Path] { + m.newFiles[file.Path] = now + } + } + + // Try to find the same file by name and restore cursor position + if cursorFileName != "" { + for i, file := range m.files { + if file.Name == cursorFileName { + m.cursor = i + goto cursorRestored + } + } + } + + // If we couldn't find the file by name, keep cursor in bounds + if m.cursor >= len(m.files) { + m.cursor = len(m.files) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + + cursorRestored: + // Continue watching for more events + if m.watcher != nil { + return m, func() tea.Msg { + return m.waitForFSEvent() + } + } + return m, nil + + case tickMsg: + // Clean up old highlights (files that appeared more than 3 seconds ago) + if m.newFiles != nil { + now := time.Now() + for path, t := range m.newFiles { + if now.Sub(t) > 3*time.Second { + delete(m.newFiles, path) + } + } + } + // Continue ticking + return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) + case tea.WindowSizeMsg: m.terminalWidth = msg.Width m.terminalHeight = msg.Height @@ -185,10 +304,17 @@ func (m pickerModel) renderItem(item pickerItem) string { normalStyle := lipgloss.NewStyle() focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + newFileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) // Yellow, bold checkboxStyle := lipgloss.NewStyle().Width(3) ageStyle := lipgloss.NewStyle().Faint(true) extStyle := lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("243")) + // Check if this is a new file + isNew := false + if m.newFiles != nil { + _, isNew = m.newFiles[item.file.Path] + } + // Checkbox checkbox := "[ ]" if item.selected { @@ -249,6 +375,11 @@ func (m pickerModel) renderItem(item pickerItem) string { return selectedStyle.Render(" " + line[2:]) } + // Highlight new files + if isNew { + return newFileStyle.Render(" " + line[2:]) + } + return normalStyle.Render(" " + line[2:]) } @@ -342,12 +473,30 @@ func getFileTypeDisplay(mimeType string) string { } // showBubbleTeaPickerWithResult shows an interactive picker and returns the full result -func showBubbleTeaPickerWithResult(files []recent.FileInfo, absoluteTime bool) (*recent.PickerResult, error) { +func showBubbleTeaPickerWithResult(files []recent.FileInfo, absoluteTime bool, refreshFunc func() ([]recent.FileInfo, error), watchDirs []string) (*recent.PickerResult, error) { m := pickerModel{ files: files, cursor: 0, selected: make(map[int]bool), absoluteTime: absoluteTime, + refreshFunc: refreshFunc, + watchDirs: watchDirs, + } + + // Setup file system watcher if we have directories to watch + if len(watchDirs) > 0 && refreshFunc != nil { + watcher, err := fsnotify.NewWatcher() + if err == nil { + m.watcher = watcher + defer func() { + _ = watcher.Close() + }() + + // Add all watch directories + for _, dir := range watchDirs { + _ = watcher.Add(dir) // Ignore errors, best effort + } + } } // Run the program inline (not fullscreen) diff --git a/cmd/clippy/picker_bubbletea_test.go b/cmd/clippy/picker_bubbletea_test.go index a452ec55..cdb1af25 100644 --- a/cmd/clippy/picker_bubbletea_test.go +++ b/cmd/clippy/picker_bubbletea_test.go @@ -36,6 +36,7 @@ func TestPickerModel(t *testing.T) { cursor: 0, selected: make(map[int]bool), absoluteTime: false, + refreshFunc: nil, // No refresh in tests } // Test initial state diff --git a/cmd/pasty/main.go b/cmd/pasty/main.go index c055c522..d195add9 100644 --- a/cmd/pasty/main.go +++ b/cmd/pasty/main.go @@ -21,6 +21,7 @@ var ( preserveFormat bool inspect bool plain bool + force bool logger *log.Logger ) @@ -92,6 +93,7 @@ Description: result, err = clippy.PasteToFileWithOptions(destination, clippy.PasteOptions{ PreserveFormat: preserveFormat, PlainTextOnly: plain, + Force: force, }) } @@ -133,6 +135,7 @@ Description: rootCmd.Flags().BoolVar(&preserveFormat, "preserve-format", false, "Preserve original image format (skip TIFF to PNG conversion)") rootCmd.Flags().BoolVar(&inspect, "inspect", false, "Show clipboard contents and types (debug mode)") rootCmd.Flags().BoolVar(&plain, "plain", false, "Force plain text output (strip all formatting)") + rootCmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite existing files without Finder-style duplicate naming") // Execute the command if err := rootCmd.Execute(); err != nil { diff --git a/go.mod b/go.mod index 5da2304c..67747915 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect diff --git a/go.sum b/go.sum index 0e5ff86e..173c11f1 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=