diff --git a/Makefile b/Makefile index 75ea8ed..0b2d36a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY_NAME=clipse -INSTALL_DIR?=/usr/local/bin/ +INSTALL_DIR?=$(HOME)/.local/bin wayland: CGO_ENABLED=0 go build -tags wayland -o $(BINARY_NAME) @@ -10,11 +10,11 @@ x11: darwin: go build -tags darwin -o $(BINARY_NAME) -run: build +run: wayland ./$(BINARY_NAME) -install: build - install -m 755 $(BINARY_NAME) $(INSTALL_DIR) +install: wayland + install -Dm 755 $(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) clean: go clean diff --git a/README.md b/README.md index 383ec37..94c4fcf 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ __If any values from this file are removed, they will not be readded when the pr | `tempDir` | string | Directory used for image files. | | `enableMouse` | bool | Enables mouse interaction in the UI. | | `enableDescription` | bool | Shows additional descriptive text for clipboard entries. | +| `search` | map | Fuzzy search engine and ranking options. See [Search](#search). | | `keyBindings` | map | Custom keybind definitions. | | `autoPaste` | map | Auto-paste options. | | `imageDisplay` | map | Image display options (basic/kitty/sixel). | @@ -380,6 +381,34 @@ Absolute paths starting with `/`, paths relative to the user home dir using `~`, | `keyBindings.up` | string | Moves selection up by one entry. | | `keyBindings.yankFilter` | string | Copies the current filter text. | +## Search + +The `search` object configures how filter matches are scored and ordered. + +| Option | Type | Description | +| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `search.engine` | string | `"default"` (existing behavior, backed by `sahilm/fuzzy`) or `"fzf"` (fzf v2 scoring from `github.com/junegunn/fzf`). Default `"default"`. | +| `search.algo` | string | `"v1"` or `"v2"` — fzf scoring algorithm. Default `"v2"`. Ignored when `engine` is `"default"`. | +| `search.caseSensitivity` | string | `"smart"`, `"respect"`, or `"ignore"`. `smart` is case-insensitive unless the query contains uppercase. Default `"smart"`. Fzf engine only. | +| `search.normalize` | bool | Strip diacritics so `cafe` matches `café`. Default `true`. Fzf engine only. | +| `search.tiebreak` | string[] | Ordering applied when fzf scores tie. Any of `"score"`, `"length"`, `"index"`, `"frecency"`. Default `["score","frecency","index"]`. Fzf engine only. | + +Opting into fzf scoring with frecency: + +```json +{ + "search": { + "engine": "fzf", + "algo": "v2", + "caseSensitivity": "smart", + "normalize": true, + "tiebreak": ["score", "frecency", "index"] + } +} +``` + +Adding `"frecency"` to `tiebreak` makes clipse track how often and how recently each entry was selected (written as `useCount` and `lastUsed` into `clipboard_history.json`) and surface frequently used entries first when fzf scores tie. + Key bindings can take multiple keys delimited by `,`. For example: diff --git a/app/model.go b/app/model.go index facc899..10cf365 100644 --- a/app/model.go +++ b/app/model.go @@ -3,6 +3,7 @@ package app import ( "fmt" "strings" + "time" "unicode" "github.com/charmbracelet/bubbles/help" @@ -12,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/savedra1/clipse/config" + "github.com/savedra1/clipse/search" "github.com/savedra1/clipse/utils" ) @@ -95,7 +97,7 @@ func NewModel() Model { del := m.newItemDelegate() clipboardList := list.New(entryItems, del, 0, 0) - clipboardList.Filter = sanitizedFilter + clipboardList.Filter = buildFilter(clipboardItems) clipboardList.KeyMap = defaultOverrides(config.ClipseConfig.KeyBindings) // override default list keys with custom values clipboardList.Title = clipboardTitle // set hardcoded title clipboardList.SetShowHelp(false) // override with custom @@ -128,12 +130,31 @@ func NewModel() Model { return m } -func sanitizedFilter(term string, targets []string) []list.Rank { - sanitized := make([]string, len(targets)) - for i, t := range targets { - sanitized[i] = stripNonPrintable(t) +func buildFilter(items []config.ClipboardItem) func(string, []string) []list.Rank { + meta := make(map[string]search.ItemMeta, len(items)) + for _, it := range items { + lastUsed, _ := time.Parse(utils.DateLayout, it.LastUsed) + key := stripNonPrintable(utils.Shorten(it.Value, config.ClipseConfig.MaxEntryLength)) + meta[key] = search.ItemMeta{UseCount: it.UseCount, LastUsed: lastUsed} + } + lookup := func(target string) search.ItemMeta { + return meta[target] + } + sc := config.ClipseConfig.Search + inner := search.Filter(search.Config{ + Engine: sc.Engine, + Algo: sc.Algo, + CaseSensitivity: sc.CaseSensitivity, + Normalize: sc.Normalize, + Tiebreak: sc.Tiebreak, + }, lookup) + return func(term string, targets []string) []list.Rank { + sanitized := make([]string, len(targets)) + for i, t := range targets { + sanitized[i] = stripNonPrintable(t) + } + return inner(term, sanitized) } - return list.DefaultFilter(term, sanitized) } func stripNonPrintable(s string) string { diff --git a/app/update.go b/app/update.go index d9ce964..a344103 100644 --- a/app/update.go +++ b/app/update.go @@ -552,6 +552,15 @@ func (m *Model) filterMatches() []string { return filteredItems } +func recordUse(timeStamp string) { + if timeStamp == "" { + return + } + if err := config.RecordUse(timeStamp); err != nil { + utils.LogERROR(fmt.Sprintf("failed to record frecency use: %s", err)) + } +} + func (m Model) handleChooseOperation(i item, cmds []tea.Cmd) (Model, []tea.Cmd, bool) { selectedItems := m.selectedItems() if len(selectedItems) < 1 { @@ -563,6 +572,8 @@ func (m Model) handleChooseOperation(i item, cmds []tea.Cmd) (Model, []tea.Cmd, display.DisplayServer.CopyText(i.titleFull) } + recordUse(i.timeStamp) + if KeepEnabled { cmds = append( cmds, @@ -583,6 +594,11 @@ func (m Model) handleChooseOperation(i item, cmds []tea.Cmd) (Model, []tea.Cmd, display.DisplayServer.CopyText(yank) + recordUse(i.timeStamp) + for _, item := range selectedItems { + recordUse(item.TimeStamp) + } + if KeepEnabled { statusMsg := "Copied to clipboard: *selected items*" display.DisplayServer.CopyText(yank) diff --git a/config/config.go b/config/config.go index 6105cd4..272961d 100644 --- a/config/config.go +++ b/config/config.go @@ -25,6 +25,15 @@ type Config struct { AutoPaste AutoPaste `json:"autoPaste"` EnableMouse bool `json:"enableMouse"` EnableDescription bool `json:"enableDescription"` + Search SearchConfig `json:"search"` +} + +type SearchConfig struct { + Engine string `json:"engine"` + Algo string `json:"algo"` + CaseSensitivity string `json:"caseSensitivity"` + Normalize bool `json:"normalize"` + Tiebreak []string `json:"tiebreak"` } type AutoPaste struct { diff --git a/config/constants.go b/config/constants.go index a9a06c2..f6db2da 100644 --- a/config/constants.go +++ b/config/constants.go @@ -85,5 +85,16 @@ func defaultConfig() Config { Keybind: defaultAutoPasteKeyBind, Buffer: defaultAutoPasteBuffer, }, + Search: defaultSearchConfig(), + } +} + +func defaultSearchConfig() SearchConfig { + return SearchConfig{ + Engine: "default", + Algo: "v2", + CaseSensitivity: "smart", + Normalize: true, + Tiebreak: []string{"score", "frecency", "index"}, } } diff --git a/config/history.go b/config/history.go index 687127b..820d6d1 100644 --- a/config/history.go +++ b/config/history.go @@ -19,6 +19,8 @@ type ClipboardItem struct { Recorded string `json:"recorded"` FilePath string `json:"filePath"` Pinned bool `json:"pinned"` + UseCount int `json:"useCount,omitempty"` + LastUsed string `json:"lastUsed,omitempty"` } type ClipboardHistory struct { @@ -335,6 +337,18 @@ func TogglePinClipboardItem(timeStamp string) (bool, error) { return pinned, nil } +func RecordUse(timeStamp string) error { + data := fileContents() + for i, item := range data.ClipboardHistory { + if item.Recorded == timeStamp { + data.ClipboardHistory[i].UseCount = item.UseCount + 1 + data.ClipboardHistory[i].LastUsed = utils.GetTime() + return WriteUpdate(data) + } + } + return nil +} + func SanitizeHistory() error { data := fileContents() newData := ClipboardHistory{} diff --git a/go.mod b/go.mod index 63b7fac..8c5ad1c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-vgo/robotgo v1.0.0 + github.com/junegunn/fzf v0.71.0 github.com/mitchellh/go-ps v1.0.0 gopkg.in/bendahl/uinput.v1 v1.2.0 ) @@ -20,11 +22,11 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gen2brain/shm v0.1.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/jezek/xgb v1.2.0 // indirect + github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/otiai10/gosseract/v2 v2.4.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect diff --git a/go.sum b/go.sum index ed8f307..831114e 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jezek/xgb v1.2.0 h1:LzgkD11wOrPnxXEqo588cnjUt4NwMHrFh/tgajo50Q0= github.com/jezek/xgb v1.2.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/junegunn/fzf v0.71.0 h1:vPmJH1MUlysSczjn2HZ6+6KSMe8HxXUy323g9x9RmUQ= +github.com/junegunn/fzf v0.71.0/go.mod h1:xlXX2/rmsccKQUnr9QOXPDi5DyV9cM0UjKy/huScBeE= +github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs= +github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/search/search.go b/search/search.go new file mode 100644 index 0000000..52cee54 --- /dev/null +++ b/search/search.go @@ -0,0 +1,206 @@ +package search + +import ( + "math" + "slices" + "sort" + "strings" + "time" + "unicode" + + "github.com/charmbracelet/bubbles/list" + "github.com/junegunn/fzf/src/algo" + "github.com/junegunn/fzf/src/util" +) + +func init() { + algo.Init("default") +} + +const ( + EngineDefault = "default" + EngineFzf = "fzf" + + AlgoV1 = "v1" + AlgoV2 = "v2" + + CaseSmart = "smart" + CaseRespect = "respect" + CaseIgnore = "ignore" + + TiebreakScore = "score" + TiebreakLength = "length" + TiebreakIndex = "index" + TiebreakFrecency = "frecency" + + frecencyHalflife = 24 * time.Hour + slab16Size = 100 * 1024 + slab32Size = 2048 +) + +type Config struct { + Engine string `json:"engine"` + Algo string `json:"algo"` + CaseSensitivity string `json:"caseSensitivity"` + Normalize bool `json:"normalize"` + Tiebreak []string `json:"tiebreak"` +} + +type ItemMeta struct { + UseCount int + LastUsed time.Time +} + +type MetaLookup func(target string) ItemMeta + +func Filter(cfg Config, metaLookup MetaLookup) func(term string, targets []string) []list.Rank { + if cfg.Engine != EngineFzf { + return list.DefaultFilter + } + return fzfFilter(cfg, metaLookup) +} + +type rankWithScore struct { + rank list.Rank + score int + length int + frecency float64 +} + +func fzfFilter(cfg Config, metaLookup MetaLookup) func(string, []string) []list.Rank { + useFrecency := metaLookup != nil && containsStr(cfg.Tiebreak, TiebreakFrecency) + tiebreak := cfg.Tiebreak + if len(tiebreak) == 0 { + tiebreak = []string{TiebreakScore, TiebreakLength, TiebreakIndex} + } + matchFn := algo.FuzzyMatchV2 + if cfg.Algo == AlgoV1 { + matchFn = algo.FuzzyMatchV1 + } + slab := util.MakeSlab(slab16Size, slab32Size) + + return func(term string, targets []string) []list.Rank { + term = strings.TrimSpace(term) + if term == "" { + out := make([]list.Rank, len(targets)) + for i := range targets { + out[i] = list.Rank{Index: i} + } + return out + } + + tokens := strings.Fields(term) + now := time.Now() + + results := make([]rankWithScore, 0, len(targets)) + for idx, target := range targets { + totalScore := 0 + var matchedPositions []int + matched := true + for _, tok := range tokens { + caseSensitive := isCaseSensitive(cfg.CaseSensitivity, tok) + text := util.ToChars([]byte(target)) + patternStr := tok + if !caseSensitive { + patternStr = strings.ToLower(patternStr) + } + pattern := []rune(patternStr) + if cfg.Normalize { + pattern = algo.NormalizeRunes(pattern) + } + res, pos := matchFn(caseSensitive, cfg.Normalize, true, &text, pattern, true, slab) + if res.Start < 0 { + matched = false + break + } + totalScore += res.Score + if pos != nil { + matchedPositions = append(matchedPositions, *pos...) + } else { + for p := res.Start; p < res.End; p++ { + matchedPositions = append(matchedPositions, p) + } + } + } + if !matched { + continue + } + fr := 0.0 + if useFrecency { + m := metaLookup(target) + fr = frecencyScore(m, now) + } + results = append(results, rankWithScore{ + rank: list.Rank{Index: idx, MatchedIndexes: matchedPositions}, + score: totalScore, + length: len(target), + frecency: fr, + }) + } + + sort.SliceStable(results, func(i, j int) bool { + return lessByTiebreak(results[i], results[j], tiebreak) + }) + + out := make([]list.Rank, len(results)) + for i, r := range results { + out[i] = r.rank + } + return out + } +} + +func lessByTiebreak(a, b rankWithScore, tiebreak []string) bool { + for _, tb := range tiebreak { + switch tb { + case TiebreakScore: + if a.score != b.score { + return a.score > b.score + } + case TiebreakLength: + if a.length != b.length { + return a.length < b.length + } + case TiebreakIndex: + if a.rank.Index != b.rank.Index { + return a.rank.Index < b.rank.Index + } + case TiebreakFrecency: + if a.frecency != b.frecency { + return a.frecency > b.frecency + } + } + } + return false +} + +func frecencyScore(m ItemMeta, now time.Time) float64 { + if m.UseCount == 0 { + return 0 + } + if m.LastUsed.IsZero() { + return float64(m.UseCount) + } + age := max(now.Sub(m.LastUsed), 0) + return float64(m.UseCount) * math.Exp(-float64(age)/float64(frecencyHalflife)) +} + +func isCaseSensitive(mode, pattern string) bool { + switch mode { + case CaseRespect: + return true + case CaseIgnore: + return false + default: // smart + for _, r := range pattern { + if unicode.IsUpper(r) { + return true + } + } + return false + } +} + +func containsStr(ss []string, s string) bool { + return slices.Contains(ss, s) +} diff --git a/tests/search/search_test.go b/tests/search/search_test.go new file mode 100644 index 0000000..c4e0b95 --- /dev/null +++ b/tests/search/search_test.go @@ -0,0 +1,154 @@ +package search_test + +import ( + "reflect" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + + "github.com/savedra1/clipse/search" +) + +func TestDefaultEngineMatchesListDefaultFilter(t *testing.T) { + targets := []string{ + "git commit -m fix", + "go test ./...", + "git checkout main", + } + terms := []string{"git", "go", "gx", "CHECKOUT"} + + cfg := search.Config{Engine: search.EngineDefault} + filter := search.Filter(cfg, nil) + + for _, term := range terms { + got := filter(term, targets) + want := list.DefaultFilter(term, targets) + if !reflect.DeepEqual(got, want) { + t.Errorf("term %q: default engine diverged from list.DefaultFilter\n got=%+v\nwant=%+v", term, got, want) + } + } +} + +func TestFzfRanksWordBoundaryAbove(t *testing.T) { + targets := []string{ + "git commit", + "go compile output", + "git checkout origin", + } + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []string{search.TiebreakScore, search.TiebreakLength, search.TiebreakIndex}, + } + filter := search.Filter(cfg, nil) + + ranks := filter("gco", targets) + if len(ranks) == 0 { + t.Fatal("expected at least one match for 'gco'") + } + top := targets[ranks[0].Index] + if top != "git checkout origin" && top != "go compile output" { + t.Errorf("expected a word-boundary match at top, got %q", top) + } + for _, r := range ranks { + if targets[r.Index] == "git commit" && targets[ranks[0].Index] == "git commit" { + t.Errorf("unexpected: 'git commit' ranked top for pattern 'gco'") + } + } +} + +func TestFzfMultiTermAnd(t *testing.T) { + targets := []string{ + "git commit", + "git checkout main", + "go test", + } + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2, Normalize: true} + filter := search.Filter(cfg, nil) + + ranks := filter("git ch", targets) + if len(ranks) != 1 || targets[ranks[0].Index] != "git checkout main" { + t.Errorf("expected only 'git checkout main', got %v", ranks) + } +} + +func TestFzfSmartCase(t *testing.T) { + targets := []string{"Hello World", "hello there"} + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2, CaseSensitivity: search.CaseSmart, Normalize: true} + filter := search.Filter(cfg, nil) + + if ranks := filter("hello", targets); len(ranks) != 2 { + t.Errorf("smart case lowercase: expected 2 matches, got %d", len(ranks)) + } + ranks := filter("Hello", targets) + if len(ranks) != 1 || targets[ranks[0].Index] != "Hello World" { + t.Errorf("smart case mixed: expected only 'Hello World', got %+v", ranks) + } +} + +func TestFzfNormalize(t *testing.T) { + targets := []string{"café au lait", "tea"} + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2, Normalize: true} + filter := search.Filter(cfg, nil) + + ranks := filter("cafe", targets) + if len(ranks) == 0 { + t.Errorf("normalize=true: expected 'cafe' to match 'café au lait'") + } +} + +func TestFrecencyTiebreak(t *testing.T) { + targets := []string{"foo bar", "foo baz"} + now := time.Now() + meta := map[string]search.ItemMeta{ + "foo bar": {UseCount: 1, LastUsed: now.Add(-48 * time.Hour)}, + "foo baz": {UseCount: 10, LastUsed: now.Add(-1 * time.Hour)}, + } + lookup := func(t string) search.ItemMeta { return meta[t] } + + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []string{search.TiebreakFrecency, search.TiebreakIndex}, + } + filter := search.Filter(cfg, lookup) + + ranks := filter("foo", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if targets[ranks[0].Index] != "foo baz" { + t.Errorf("frecency tiebreak: expected 'foo baz' first, got %q", targets[ranks[0].Index]) + } +} + +func TestFrecencyDisabledWhenLookupNil(t *testing.T) { + targets := []string{"foo bar", "foo baz"} + cfg := search.Config{ + Engine: search.EngineFzf, + Algo: search.AlgoV2, + Normalize: true, + Tiebreak: []string{search.TiebreakFrecency, search.TiebreakIndex}, + } + filter := search.Filter(cfg, nil) + ranks := filter("foo", targets) + if len(ranks) != 2 { + t.Fatalf("expected 2 matches, got %d", len(ranks)) + } + if ranks[0].Index != 0 { + t.Errorf("nil lookup: expected index 0 first, got %d", ranks[0].Index) + } +} + +func TestFzfEmptyTerm(t *testing.T) { + targets := []string{"a", "b", "c"} + cfg := search.Config{Engine: search.EngineFzf, Algo: search.AlgoV2} + filter := search.Filter(cfg, nil) + ranks := filter("", targets) + if len(ranks) != 3 { + t.Errorf("empty term should pass all items, got %d", len(ranks)) + } +}