From 6f511ae66aacd9653444fc4701204a5943a0e15d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 10 Jul 2025 10:30:48 +0530 Subject: [PATCH] Add a bunch of settings to control filesystem scanning --- kittens/choose_files/main.go | 91 +++++++++++++++++++--- kittens/choose_files/main.py | 26 +++++++ kittens/choose_files/scan.go | 123 +++++++++++++++++++++--------- kittens/choose_files/scan_test.go | 6 +- 4 files changed, 199 insertions(+), 47 deletions(-) diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 32ef31535..786265e1d 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -9,9 +9,11 @@ import ( "slices" "strconv" "strings" + "sync" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/config" + "github.com/kovidgoyal/kitty/tools/ignorefiles" "github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/loop" @@ -112,6 +114,10 @@ type State struct { current_filter string filter_map map[string]Filter filter_names []string + show_hidden bool + respect_ignores bool + sort_by_last_modified bool + global_ignores ignorefiles.IgnoreFile save_file_cdir string selections []string @@ -121,13 +127,17 @@ type State struct { redraw_needed bool } -func (s State) BaseDir() string { return utils.IfElse(s.base_dir == "", default_cwd, s.base_dir) } -func (s State) Filter() Filter { return s.filter_map[s.current_filter] } -func (s State) SelectDirs() bool { return s.select_dirs } -func (s State) Multiselect() bool { return s.multiselect } -func (s State) String() string { return utils.Repr(s) } -func (s State) SearchText() string { return s.search_text } -func (s State) OnlyDirs() bool { return s.mode.OnlyDirs() } +func (s State) ShowHidden() bool { return s.show_hidden } +func (s State) RespectIgnores() bool { return s.respect_ignores } +func (s State) SortByLastModified() bool { return s.sort_by_last_modified } +func (s State) GlobalIgnores() ignorefiles.IgnoreFile { return s.global_ignores } +func (s State) BaseDir() string { return utils.IfElse(s.base_dir == "", default_cwd, s.base_dir) } +func (s State) Filter() Filter { return s.filter_map[s.current_filter] } +func (s State) SelectDirs() bool { return s.select_dirs } +func (s State) Multiselect() bool { return s.multiselect } +func (s State) String() string { return utils.Repr(s) } +func (s State) SearchText() string { return s.search_text } +func (s State) OnlyDirs() bool { return s.mode.OnlyDirs() } func (s *State) SetSearchText(val string) { if s.search_text != val { s.search_text = val @@ -244,7 +254,7 @@ func (h *Handler) OnInitialize() (ans string, err error) { h.lp.AllowLineWrapping(false) h.lp.SetCursorShape(loop.BAR_CURSOR, true) h.lp.StartBracketedPaste() - h.result_manager.set_root_dir(h.state.CurrentDir(), h.state.Filter()) + h.result_manager.set_root_dir() h.draw_screen() return } @@ -278,7 +288,7 @@ func (h *Handler) toggle_selection() bool { func (h *Handler) change_current_dir(dir string) { if dir != h.state.CurrentDir() { h.state.SetCurrentDir(dir) - h.result_manager.set_root_dir(h.state.CurrentDir(), h.state.Filter()) + h.result_manager.set_root_dir() h.state.last_render = render_state{} } } @@ -286,7 +296,7 @@ func (h *Handler) change_current_dir(dir string) { func (h *Handler) set_query(q string) { if q != h.state.SearchText() { h.state.SetSearchText(q) - h.result_manager.set_query(h.state.SearchText(), h.state.Filter()) + h.result_manager.set_query() h.state.last_render = render_state{} } } @@ -294,7 +304,7 @@ func (h *Handler) set_query(q string) { func (h *Handler) set_filter(filter_name string) { if filter_name != h.state.current_filter { h.state.current_filter = filter_name - h.result_manager.set_filter(h.state.Filter()) + h.result_manager.set_filter() h.state.last_render = render_state{} } } @@ -435,7 +445,32 @@ func (h *Handler) OnText(text string, from_key_event, in_bracketed_paste bool) ( return } -func (h *Handler) set_state_from_config(_ *Config, opts *Options) (err error) { +type CachedValues struct { + Show_hidden bool `json:"show_hidden"` + Respect_ignores bool `json:"respect_ignores"` + Sort_by_last_modified bool `json:"sort_by_last_modified"` +} + +const cache_filename = "choose-files.json" + +var cached_values = sync.OnceValue(func() *CachedValues { + ans := CachedValues{Respect_ignores: true} + fname := filepath.Join(utils.CacheDir(), cache_filename) + if data, err := os.ReadFile(fname); err == nil { + _ = json.Unmarshal(data, &ans) + } + return &ans +}) + +func (s State) save_cached_values() { + c := CachedValues{Show_hidden: s.show_hidden, Respect_ignores: s.respect_ignores, Sort_by_last_modified: s.sort_by_last_modified} + fname := filepath.Join(utils.CacheDir(), cache_filename) + if data, err := json.Marshal(c); err == nil { + _ = os.WriteFile(fname, data, 0600) + } +} + +func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) { h.state = State{} switch opts.Mode { case "file": @@ -504,6 +539,37 @@ func (h *Handler) set_state_from_config(_ *Config, opts *Options) (err error) { h.state.filter_map[name] = CombinedFilter(filters...) } } + h.state.sort_by_last_modified = false + h.state.respect_ignores = true + h.state.show_hidden = false + switch conf.Show_hidden { + case Show_hidden_true, Show_hidden_y, Show_hidden_yes: + h.state.show_hidden = true + case Show_hidden_false, Show_hidden_n, Show_hidden_no: + h.state.show_hidden = false + case Show_hidden_last: + h.state.show_hidden = cached_values().Show_hidden + } + switch conf.Respect_ignores { + case Respect_ignores_true, Respect_ignores_y, Respect_ignores_yes: + h.state.respect_ignores = true + case Respect_ignores_false, Respect_ignores_n, Respect_ignores_no: + h.state.respect_ignores = false + case Respect_ignores_last: + h.state.respect_ignores = cached_values().Respect_ignores + } + switch conf.Sort_by_last_modified { + case Sort_by_last_modified_true, Sort_by_last_modified_y, Sort_by_last_modified_yes: + h.state.sort_by_last_modified = true + case Sort_by_last_modified_false, Sort_by_last_modified_n, Sort_by_last_modified_no: + h.state.sort_by_last_modified = false + case Sort_by_last_modified_last: + h.state.sort_by_last_modified = cached_values().Sort_by_last_modified + } + h.state.global_ignores = ignorefiles.NewGitignore() + if err = h.state.global_ignores.LoadLines(conf.Ignore...); err != nil { + return err + } return } @@ -606,6 +672,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { return } err = lp.Run() + handler.state.save_cached_values() if err != nil { write_output(nil, false, "") return 1, err diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index a56c129c9..914363015 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -17,6 +17,32 @@ opt = definition.add_option map = definition.add_map mma = definition.add_mouse_map +agr('Filesystem scanning') + +opt('show_hidden', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' +Whether to show hidden files. The default value of :code:`last` means remember the last +used value. This setting can be toggled withing the program.''') + +opt('sort_by_last_modified', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' +Whether to sort the list of entries by last modified, instead of name. Note that sorting only applies +before any query is entered. Once a query is entered entries are sorted by their matching score. +The default value of :code:`last` means remember the last +used value. This setting can be toggled withing the program.''') + +opt('respect_ignores', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' +Whether to respect .gitignore and .ignore files and the :opt:`ignore` setting. +The default value of :code:`last` means remember the last used value. +This setting can be toggled withing the program.''') + +opt('+ignore', '', add_to_default=False, long_text=''' +An ignore pattern to ignore matched files. Uses the same sytax as :code:`.gitignore` files (see :code:`man gitignore`). +Anchored patterns match with respect to whatever directory is currently being displayed. +Can be specified multiple times to use multiple patterns. Note that every pattern +has to be checked against every file, so use sparingly. +''') + +egr() + def main(args: list[str]) -> None: raise SystemExit('This must be run as kitten choose-files') diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index ee604f84b..d6ef189c9 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -3,6 +3,7 @@ package choose_files import ( "bytes" "cmp" + "encoding/binary" "fmt" "io/fs" "math" @@ -79,7 +80,9 @@ type FileSystemScanner struct { file_reader func(path string) ([]byte, error) filter_func func(filename string) bool global_gitignore ignorefiles.IgnoreFile + global_ignore ignorefiles.IgnoreFile respect_ignores, show_hidden bool + sort_by_last_modified bool err error } @@ -92,6 +95,7 @@ func new_filesystem_scanner(root_dir string, notify chan bool, filter_func func( ans.file_reader = os.ReadFile ans.filter_func = utils.IfElse(filter_func == nil, accept_all, filter_func) ans.global_gitignore = ignorefiles.NewGitignore() + ans.global_ignore = ignorefiles.NewGitignore() ans.respect_ignores = true ans.show_hidden = false return ans @@ -326,11 +330,23 @@ func (fss *FileSystemScanner) worker() { } else { arena[i].buf[0] = '1' } - n := as_lower(arena[i].name, arena[i].buf[1:]) - arena[i].sort_key = arena[i].buf[:1+n] + if fss.sort_by_last_modified { + var ts time.Time + if info, err := e.Info(); err == nil { + ts = info.ModTime() + } + binary.BigEndian.PutUint64(arena[i].buf[1:], uint64(ts.UnixNano())) + arena[i].sort_key = arena[i].buf[:1+8] + } else { + n := as_lower(arena[i].name, arena[i].buf[1:]) + arena[i].sort_key = arena[i].buf[:1+n] + } sortable = append(sortable, &arena[i]) } if fss.respect_ignores { + if is_root && fss.global_ignore.Len() > 0 { + add_ignore_file_from_impl(fss.global_ignore) + } if has_dot_git { if fss.global_gitignore.Len() > 0 { add_ignore_file_from_impl(fss.global_gitignore) @@ -390,20 +406,21 @@ func (fss *FileSystemScanner) worker() { } type FileSystemScorer struct { - scanner Scanner - keep_going, is_complete atomic.Bool - root_dir, query string - filter Filter - only_dirs bool - mutex sync.Mutex - sorted_results *SortedResults - on_results func(error, bool) - current_worker_wait *sync.WaitGroup - scorer *fzf.FuzzyMatcher - dir_reader func(path string) ([]fs.DirEntry, error) - file_reader func(path string) ([]byte, error) - global_gitignore ignorefiles.IgnoreFile - respect_ignores, show_hidden bool + scanner Scanner + keep_going, is_complete atomic.Bool + root_dir, query string + filter Filter + only_dirs bool + mutex sync.Mutex + sorted_results *SortedResults + on_results func(error, bool) + current_worker_wait *sync.WaitGroup + scorer *fzf.FuzzyMatcher + dir_reader func(path string) ([]fs.DirEntry, error) + file_reader func(path string) ([]byte, error) + global_gitignore, global_ignore ignorefiles.IgnoreFile + respect_ignores, show_hidden bool + sort_by_last_modified bool } func NewFileSystemScorer(root_dir, query string, filter Filter, only_dirs bool, on_results func(error, bool)) (ans *FileSystemScorer) { @@ -433,7 +450,11 @@ func (fss *FileSystemScorer) Start() { } else { sc.global_gitignore = ignorefiles.GlobalGitignore() } + if fss.global_ignore != nil { + sc.global_ignore = fss.global_ignore + } sc.show_hidden, sc.respect_ignores = fss.show_hidden, fss.respect_ignores + sc.sort_by_last_modified = fss.sort_by_last_modified fss.scanner = sc fss.scanner.Start() } else { @@ -497,6 +518,12 @@ func (fss *FileSystemScorer) Change_respect_ignores(val bool) { } } +func (fss *FileSystemScorer) Change_sort_by_last_modified(val bool) { + if fss.sort_by_last_modified != val { + fss.change_scanner_setting(func() { fss.sort_by_last_modified = val }) + } +} + func (fss *FileSystemScorer) worker(on_results chan bool, worker_wait *sync.WaitGroup) { defer func() { fss.is_complete.Store(true) @@ -598,6 +625,11 @@ type Settings interface { OnlyDirs() bool CurrentDir() string SearchText() string + ShowHidden() bool + RespectIgnores() bool + SortByLastModified() bool + Filter() Filter + GlobalIgnores() ignorefiles.IgnoreFile } type ResultManager struct { @@ -619,6 +651,15 @@ func NewResultManager(err_chan chan error, settings Settings, WakeupMainThread f return ans } +func (m *ResultManager) new_scorer() { + root_dir := m.current_root_dir() + query := m.settings.SearchText() + m.scorer = NewFileSystemScorer(root_dir, query, m.settings.Filter(), m.settings.OnlyDirs(), m.on_results) + m.scorer.respect_ignores = m.settings.RespectIgnores() + m.scorer.show_hidden = m.settings.ShowHidden() + m.scorer.global_ignore = m.settings.GlobalIgnores() +} + func (m *ResultManager) on_results(err error, is_finished bool) { if err != nil { m.report_errors <- err @@ -633,50 +674,64 @@ func (m *ResultManager) on_results(err error, is_finished bool) { } } -func (m *ResultManager) set_root_dir(root_dir string, filter Filter) { +func (m *ResultManager) current_root_dir() string { var err error + root_dir := m.settings.CurrentDir() if root_dir == "" || root_dir == "." { if root_dir, err = os.Getwd(); err != nil { - return + return "/" } } root_dir = utils.Expanduser(root_dir) if root_dir, err = filepath.Abs(root_dir); err != nil { - return + return "/" } + return root_dir +} + +func (m *ResultManager) set_root_dir() { if m.scorer != nil { m.scorer.Cancel() } - _ = os.Chdir(root_dir) // this is so the terminal emulator can read the wd for launch --directory=current - m.scorer = NewFileSystemScorer(root_dir, "", filter, m.settings.OnlyDirs(), m.on_results) + _ = os.Chdir(m.current_root_dir()) // this is so the terminal emulator can read the wd for launch --directory=current + m.new_scorer() m.mutex.Lock() m.last_wakeup_at = time.Time{} m.mutex.Unlock() m.scorer.Start() } -func (m *ResultManager) set_query(query string, filter Filter) { +func (m *ResultManager) set_something(callback func()) { m.mutex.Lock() m.last_wakeup_at = time.Time{} m.mutex.Unlock() if m.scorer == nil { - m.scorer = NewFileSystemScorer(".", "", filter, m.settings.OnlyDirs(), m.on_results) + m.new_scorer() m.scorer.Start() } else { - m.scorer.Change_query(query) + callback() } + } -func (m *ResultManager) set_filter(f Filter) { - m.mutex.Lock() - m.last_wakeup_at = time.Time{} - m.mutex.Unlock() - if m.scorer == nil { - m.scorer = NewFileSystemScorer(".", "", f, m.settings.OnlyDirs(), m.on_results) - m.scorer.Start() - } else { - m.scorer.Change_filter(f) - } +func (m *ResultManager) set_query() { + m.set_something(func() { m.scorer.Change_query(m.settings.SearchText()) }) +} + +func (m *ResultManager) set_filter() { + m.set_something(func() { m.scorer.Change_filter(m.settings.Filter()) }) +} + +func (m *ResultManager) set_show_hidden() { + m.set_something(func() { m.scorer.Change_show_hidden(m.settings.ShowHidden()) }) +} + +func (m *ResultManager) set_respect_ignores() { + m.set_something(func() { m.scorer.Change_respect_ignores(m.settings.RespectIgnores()) }) +} + +func (m *ResultManager) set_sort_by_last_modified() { + m.set_something(func() { m.scorer.Change_sort_by_last_modified(m.settings.SortByLastModified()) }) } func (h *Handler) get_results() (ans *SortedResults, is_complete bool) { diff --git a/kittens/choose_files/scan_test.go b/kittens/choose_files/scan_test.go index ad2dbc2e2..6410eb2e6 100644 --- a/kittens/choose_files/scan_test.go +++ b/kittens/choose_files/scan_test.go @@ -154,10 +154,14 @@ func TestChooseFilesIgnore(t *testing.T) { s.dir_reader = root.ReadDir s.file_reader = root.ReadFile s.global_gitignore = ignorefiles.NewGitignore() + s.global_ignore = ignorefiles.NewGitignore() s.respect_ignores = respect_ignores if err := s.global_gitignore.LoadLines("*.png", "s/3"); err != nil { t.Fatal(err) } + if err := s.global_ignore.LoadLines("x/3"); err != nil { + t.Fatal(err) + } s.Start() for range ch { } @@ -170,7 +174,7 @@ func TestChooseFilesIgnore(t *testing.T) { t.Fatalf("Incorrect ignoring:\n%s", diff) } } - r(true, `x y b c.png x/s x/1 x/2 x/3 y/s y/3 y/4 y/5 x/s/m y/s/6`) + r(true, `x y b c.png x/s x/1 x/2 y/s y/3 y/4 y/5 x/s/m y/s/6`) r(false, `x y a b c.png x/s x/1 x/2 x/3 y/s y/3 y/4 y/5 x/s/m x/s/n y/s/3 y/s/4 y/s/5 y/s/6`) }