diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 251f38b8c..07cafee0b 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "regexp" + "strconv" "strings" "github.com/kovidgoyal/kitty/tools/cli" @@ -16,6 +17,12 @@ import ( var _ = fmt.Print var debugprintln = tty.DebugPrintln +type ScorePattern struct { + pat *regexp.Regexp + op func(float64, float64) float64 + val float64 +} + type State struct { base_dir string current_dir string @@ -23,6 +30,7 @@ type State struct { multiselect bool max_depth int exclude_patterns []*regexp.Regexp + score_patterns []ScorePattern search_text string } @@ -33,6 +41,7 @@ func (s State) MaxDepth() int { return utils.IfElse(s.max_de func (s State) String() string { return utils.Repr(s) } func (s State) SearchText() string { return s.search_text } func (s State) ExcludePatterns() []*regexp.Regexp { return s.exclude_patterns } +func (s State) ScorePatterns() []ScorePattern { return s.score_patterns } func (s State) CurrentDir() string { return utils.IfElse(s.current_dir == "", s.BaseDir(), s.current_dir) } @@ -114,8 +123,13 @@ func (h *Handler) OnText(text string, from_key_event, in_bracketed_paste bool) ( return h.draw_screen() } +func mult(a, b float64) float64 { return a * b } +func sub(a, b float64) float64 { return a - b } +func add(a, b float64) float64 { return a + b } +func div(a, b float64) float64 { return a / b } + func (h *Handler) set_state_from_config(conf *Config) (err error) { - h.state.max_depth = int(conf.Max_depth) + h.state = State{max_depth: int(conf.Max_depth)} h.state.exclude_patterns = make([]*regexp.Regexp, 0, len(conf.Exclude_directory)) seen := map[string]*regexp.Regexp{} for _, x := range conf.Exclude_directory { @@ -130,6 +144,25 @@ func (h *Handler) set_state_from_config(conf *Config) (err error) { } } h.state.exclude_patterns = utils.Values(seen) + fmap := map[string]func(float64, float64) float64{ + "*=": mult, "+=": add, "-=": sub, "/=": div} + h.state.score_patterns = make([]ScorePattern, len(conf.Modify_score)) + for i, x := range conf.Modify_score { + p, rest, _ := strings.Cut(x, " ") + if h.state.score_patterns[i].pat, err = regexp.Compile(p); err == nil { + op, val, _ := strings.Cut(rest, " ") + if h.state.score_patterns[i].val, err = strconv.ParseFloat(val, 64); err != nil { + return fmt.Errorf("The modify score value %#v is invalid: %w", val, err) + } + if h.state.score_patterns[i].op = fmap[op]; h.state.score_patterns[i].op == nil { + return fmt.Errorf("The modify score operator %#v is unknown", op) + } + + } else { + return fmt.Errorf("The modify score pattern %#v is invalid: %w", x, err) + } + + } return } diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 95e5fe6c9..ca7830faf 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -24,7 +24,8 @@ add_to_default=True, long_text=''' Regular expression to exclude directories. Matching directories will not be recursed into, but -you can still or change into them to inspect their contents. Can be specified multiple times. Matches against the absolute path to the directory. +you can still or change into them to inspect their contents. Can be specified multiple times. +Matches against the absolute path to the directory. If the pattern starts with :code:`!`, the :code:`!` is removed and the remaining pattern is removed from the list of patterns. This can be used to remove the default excluded directory patterns. ''', @@ -37,6 +38,13 @@ The maximum depth to which to scan the filesystem for matches. Using large values will slow things down considerably. The better approach is to use a small value and first change to the directory of interest then actually select the file of interest. ''') + +opt('+modify_score', r'(^|/)\.[^/]+(/|$) *= 0.5', add_to_default=True, long_text=''' +Modify the score of items matching the specified regular expression (matches against the absolute path). +Can be used to make certain files and directories less or more prominent in the results. +Can be specified multiple times. The default includes rules to reduce the score of hidden items. +The syntax is :code:`regular-expression operator value`. Supported operators are: :code:`*=, +=, -=, /=`. +''') egr() def main(args: list[str]) -> None: diff --git a/kittens/choose_files/results.go b/kittens/choose_files/results.go index b6f26b0f1..fc84ea9ca 100644 --- a/kittens/choose_files/results.go +++ b/kittens/choose_files/results.go @@ -56,7 +56,7 @@ func (h *Handler) draw_no_matches_message(in_progress bool) { const matching_position_style = "fg=green" -func (h *Handler) draw_matching_result(r ResultItem) { +func (h *Handler) draw_matching_result(r *ResultItem) { icon := icon_for(r.abspath, r.dir_entry) h.lp.MoveCursorHorizontally(1) p, s, _ := strings.Cut(h.lp.SprintStyled(matching_position_style, " "), " ") @@ -129,7 +129,7 @@ func icon_for(path string, x os.DirEntry) string { return ans } -func (h *Handler) draw_column_of_matches(matches []ResultItem, x, available_width, num_extra_matches int) { +func (h *Handler) draw_column_of_matches(matches []*ResultItem, x, available_width, num_extra_matches int) { for i, m := range matches { h.lp.QueueWriteString("\r") h.lp.MoveCursorHorizontally(x) @@ -154,7 +154,7 @@ func (h *Handler) draw_column_of_matches(matches []ResultItem, x, available_widt } } -func (h *Handler) draw_list_of_results(matches []ResultItem, y, height int) { +func (h *Handler) draw_list_of_results(matches []*ResultItem, y, height int) { if len(matches) == 0 || height < 2 { return } @@ -180,7 +180,7 @@ func (h *Handler) draw_list_of_results(matches []ResultItem, y, height int) { } } -func (h *Handler) draw_results(y, bottom_margin int, matches []ResultItem, in_progress bool) (height int) { +func (h *Handler) draw_results(y, bottom_margin int, matches []*ResultItem, in_progress bool) (height int) { height = h.screen_size.height - y - bottom_margin h.lp.MoveCursorTo(1, 1+y) h.draw_frame(h.screen_size.width, height) diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index 292122d90..a80873c97 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -7,6 +7,7 @@ import ( "path/filepath" "regexp" "slices" + "sort" "strings" "sync" @@ -33,7 +34,7 @@ type ScanCache struct { mutex sync.Mutex root_dir, search_text string in_progress bool - matches []ResultItem + matches []*ResultItem } func (sc *ScanCache) get_cached_entries(root_dir string) (ans []ResultItem, found bool) { @@ -93,48 +94,72 @@ func (sc *ScanCache) fs_scan(root_dir, current_dir string, max_depth int, exclud return } -func (sc *ScanCache) scan(root_dir, search_text string, max_depth int, exclude_patterns []*regexp.Regexp) (ans []ResultItem) { - seen := make(map[string]bool, 1024) - ans = sc.fs_scan(root_dir, root_dir, max_depth, exclude_patterns, seen) - if search_text == "" { - slices.SortFunc(ans, func(a, b ResultItem) int { - switch a.dir_entry.IsDir() { - case true: - switch b.dir_entry.IsDir() { - case true: - return strings.Compare(strings.ToLower(a.text), strings.ToLower(b.text)) - case false: - return -1 - } - case false: - switch b.dir_entry.IsDir() { - case true: - return 1 - case false: - return strings.Compare(strings.ToLower(a.text), strings.ToLower(b.text)) - } - } - return 0 - }) - } else { - pm := make(map[string]ResultItem, len(ans)) - for _, x := range ans { - pm[x.text] = x - } - matches := utils.Filter(subseq.ScoreItems(search_text, utils.Keys(pm), subseq.Options{}), func(x *subseq.Match) bool { - return x.Score > 0 - }) - slices.SortFunc(matches, func(a, b *subseq.Match) int { return cmp.Compare(b.Score, a.Score) }) - ans = utils.Map(func(m *subseq.Match) ResultItem { - x := pm[m.Text] - x.positions = m.Positions - return x - }, matches) +func sort_items_without_search_text(items []ResultItem) (ans []*ResultItem) { + type s struct { + ltext string + num_of_slashes int + is_dir bool + is_hidden bool + r *ResultItem } - return ans + hidden_pat := regexp.MustCompile(`(^|/)\.[^/]+(/|$)`) + d := utils.Map(func(x ResultItem) s { + return s{strings.ToLower(x.text), strings.Count(x.text, "/"), x.dir_entry.IsDir(), hidden_pat.MatchString(x.abspath), &x} + }, items) + sort.Slice(d, func(i, j int) bool { + a, b := d[i], d[j] + if a.num_of_slashes == b.num_of_slashes { + if a.is_dir == b.is_dir { + if a.is_hidden == b.is_hidden { + return a.ltext < b.ltext + } + return b.is_hidden + } + return a.is_dir + } + return a.num_of_slashes < b.num_of_slashes + }) + return utils.Map(func(s s) *ResultItem { return s.r }, d) } -func (h *Handler) get_results() (ans []ResultItem, in_progress bool) { +func get_modified_score(r *ResultItem, score float64, score_patterns []ScorePattern) float64 { + for _, sp := range score_patterns { + if sp.pat.MatchString(r.abspath) { + score = sp.op(score, sp.val) + } + } + return score +} + +func (sc *ScanCache) scan(root_dir, search_text string, max_depth int, exclude_patterns []*regexp.Regexp, score_patterns []ScorePattern) (ans []*ResultItem) { + seen := make(map[string]bool, 1024) + matches := sc.fs_scan(root_dir, root_dir, max_depth, exclude_patterns, seen) + if search_text == "" { + ans = sort_items_without_search_text(matches) + return + } + pm := make(map[string]*ResultItem, len(ans)) + for _, x := range matches { + nx := x + pm[x.text] = &nx + } + matches2 := utils.Filter(subseq.ScoreItems(search_text, utils.Keys(pm), subseq.Options{}), func(x *subseq.Match) bool { + return x.Score > 0 + }) + type s struct { + r *ResultItem + score float64 + } + ss := utils.Map(func(m *subseq.Match) s { + x := pm[m.Text] + x.positions = m.Positions + return s{x, get_modified_score(x, m.Score, score_patterns)} + }, matches2) + slices.SortFunc(ss, func(a, b s) int { return cmp.Compare(b.score, a.score) }) + return utils.Map(func(s s) *ResultItem { return s.r }, ss) +} + +func (h *Handler) get_results() (ans []*ResultItem, in_progress bool) { sc := &h.scan_cache sc.mutex.Lock() defer sc.mutex.Unlock() @@ -150,8 +175,9 @@ func (h *Handler) get_results() (ans []ResultItem, in_progress bool) { search_text := h.state.SearchText() sc.root_dir = root_dir sc.search_text = search_text + md, ep, sp := h.state.MaxDepth(), h.state.ExcludePatterns(), h.state.ScorePatterns() go func() { - results := sc.scan(root_dir, search_text, h.state.MaxDepth(), h.state.ExcludePatterns()) + results := sc.scan(root_dir, search_text, md, ep, sp) sc.mutex.Lock() defer sc.mutex.Unlock() if root_dir == sc.root_dir && search_text == sc.search_text {