Work on filter support

This commit is contained in:
Kovid Goyal
2025-07-04 13:37:56 +05:30
parent 4cf4a19c69
commit 76d0991b76
6 changed files with 242 additions and 22 deletions

View File

@@ -0,0 +1,78 @@
package choose_files
import (
"fmt"
"path/filepath"
"strings"
"github.com/kovidgoyal/kitty/tools/utils"
)
var _ = fmt.Print
type Filter struct {
Name, Type, Pattern string
Match func(filename string) bool
}
func (f Filter) String() string {
return fmt.Sprintf("%s:%s:%s", f.Type, f.Pattern, f.Name)
}
func (f Filter) Equal(other Filter) bool {
return f.Type == other.Type && f.Pattern == other.Pattern
}
func NewFilter(spec string) (*Filter, error) {
parts := strings.SplitN(spec, ":", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("%#v is not a valid filter specifier, must have at least two colons", spec)
}
ans := &Filter{Name: parts[2], Pattern: parts[1], Type: parts[0]}
if _, err := filepath.Match(ans.Pattern, "test"); err != nil {
return nil, fmt.Errorf("%#v is not a valid glob pattern with error: %w", ans.Pattern, err)
}
if ans.Pattern != "*" && ans.Pattern != "" {
switch ans.Type {
case "glob":
ans.Match = func(filename string) bool {
m, _ := filepath.Match(ans.Pattern, filename)
return m
}
case "mime":
ans.Match = func(filename string) bool {
mime := utils.GuessMimeType(filename)
if mime == "" {
return false
}
m, _ := filepath.Match(ans.Pattern, mime)
return m
}
default:
return nil, fmt.Errorf("%#v is not a valid filter type", ans.Type)
}
}
return ans, nil
}
func CombinedFilter(filters ...Filter) Filter {
if len(filters) == 0 {
return Filter{}
}
for _, f := range filters {
if f.Match == nil {
return f
}
}
ans := filters[0]
matchers := utils.Map(func(f Filter) func(filename string) bool { return f.Match }, filters)
ans.Match = func(filename string) bool {
for _, m := range matchers {
if m(filename) {
return true
}
}
return false
}
return ans
}

View File

@@ -0,0 +1,49 @@
package choose_files
import (
"fmt"
"strings"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/utils/style"
"github.com/kovidgoyal/kitty/tools/wcswidth"
)
var _ = fmt.Print
func (h *Handler) draw_footer() (num_lines int, err error) {
lines := []string{}
screen_width := h.screen_size.width
sctx := style.Context{AllowEscapeCodes: true}
if len(h.state.filter_map) > 0 {
buf := strings.Builder{}
pos := 0
current_style := sctx.SprintFunc("italic fg=green intense")
non_current_style := sctx.SprintFunc("dim")
w := func(text string, sfunc func(...any) string) {
sz := wcswidth.Stringwidth(text)
if sz+pos >= screen_width {
lines = append(lines, buf.String())
pos = 0
buf.Reset()
}
if sfunc != nil {
text = sfunc(text)
}
buf.WriteString(text)
pos += sz
}
w("Filter:", nil)
for _, name := range h.state.filter_names {
w(" "+name, utils.IfElse(name == h.state.current_filter, current_style, non_current_style))
}
if s := buf.String(); s != "" {
lines = append(lines, s)
}
}
if len(lines) > 0 {
h.lp.MoveCursorTo(1, h.screen_size.height-len(lines)+1)
h.lp.QueueWriteString(strings.Join(lines, "\r\n"))
}
return len(lines), err
}

View File

@@ -18,7 +18,7 @@ import (
"github.com/kovidgoyal/kitty/tools/utils"
)
// TODO: Comboboxes, multifile selections, save file name, file/dir modes
// TODO: multifile selections, save file name completion
var _ = fmt.Print
var debugprintln = tty.DebugPrintln
@@ -108,6 +108,9 @@ type State struct {
suggested_save_file_name string
window_title string
screen Screen
current_filter string
filter_map map[string]Filter
filter_names []string
save_file_cdir string
selections []string
@@ -116,6 +119,7 @@ type State struct {
}
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) }
@@ -190,7 +194,11 @@ func (h *Handler) draw_screen() (err error) {
h.draw_search_bar(0)
}()
y := SEARCH_BAR_HEIGHT
y += h.draw_results(y, 2, matches, !is_complete)
footer_height, err := h.draw_footer()
if err != nil {
return err
}
y += h.draw_results(y, footer_height, matches, !is_complete)
case SAVE_FILE:
err = h.draw_save_file_name_screen()
}
@@ -227,7 +235,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.result_manager.set_root_dir(h.state.CurrentDir(), h.state.Filter())
h.draw_screen()
return
}
@@ -261,7 +269,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.result_manager.set_root_dir(h.state.CurrentDir(), h.state.Filter())
h.state.last_render = render_state{}
}
}
@@ -269,7 +277,15 @@ 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.result_manager.set_query(h.state.SearchText(), h.state.Filter())
h.state.last_render = render_state{}
}
}
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.state.last_render = render_state{}
}
}
@@ -411,6 +427,41 @@ func (h *Handler) set_state_from_config(_ *Config, opts *Options) (err error) {
}
}
}
h.state.filter_map = nil
h.state.current_filter = ""
if len(opts.FileFilter) > 0 {
has_all_files := false
fmap := make(map[string][]Filter)
seen := utils.NewSet[string](len(opts.FileFilter))
for _, x := range opts.FileFilter {
f, ferr := NewFilter(x)
if ferr != nil {
return ferr
}
if f.Match == nil {
has_all_files = true
}
if h.state.current_filter == "" {
h.state.current_filter = f.Name
}
fmap[f.Name] = append(fmap[f.Name], *f)
if !seen.Has(f.Name) {
seen.Add(f.Name)
h.state.filter_names = append(h.state.filter_names, f.Name)
}
}
if !has_all_files {
af, _ := NewFilter("glob:*:All files")
fmap[af.Name] = append(fmap[af.Name], *af)
if !seen.Has(af.Name) {
h.state.filter_names = append(h.state.filter_names, af.Name)
}
}
h.state.filter_map = make(map[string]Filter)
for name, filters := range fmap {
h.state.filter_map[name] = CombinedFilter(filters...)
}
}
return
}

View File

@@ -32,6 +32,16 @@ default=file
The type of object(s) to select
--file-filter
type=list
A list of filters to restrict the displayed files. Can be either mimetypes, or glob style patterns. Can be specified multiple times.
The syntax is :code:`type:expression:Descriptive Name`.
For example: :code:`mime:image/png:Images` and :code:`mime:image/gif:Images` and :code:`glob:*.[tT][xX][Tt]:Text files`.
Note that glob patterns are case-sensitive. The mimetype specification is treated as a glob expressions as well, so you can,
for example, use :code:`mime:text/*` to match all text files. The first filter in the list will be applied by default. Use a filter
such as :code:`glob:*:All` to match all files. Note that filtering only appies to files, not directories.
--suggested-save-file-name
A suggested name when picking a save file.

View File

@@ -278,6 +278,7 @@ 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
@@ -286,10 +287,11 @@ type FileSystemScorer struct {
scorer *fzf.FuzzyMatcher
}
func NewFileSystemScorer(root_dir, query string, only_dirs bool, on_results func(error, bool)) (ans *FileSystemScorer) {
func NewFileSystemScorer(root_dir, query string, filter Filter, only_dirs bool, on_results func(error, bool)) (ans *FileSystemScorer) {
return &FileSystemScorer{
query: query, root_dir: root_dir, only_dirs: only_dirs, on_results: on_results,
scorer: fzf.NewFuzzyMatcher(fzf.PATH_SCHEME), sorted_results: NewSortedResults()}
query: query, root_dir: root_dir, only_dirs: only_dirs, filter: filter, on_results: on_results,
scorer: fzf.NewFuzzyMatcher(fzf.PATH_SCHEME), sorted_results: NewSortedResults(),
}
}
func (fss *FileSystemScorer) lock() { fss.mutex.Lock() }
@@ -325,6 +327,21 @@ func (fss *FileSystemScorer) Change_query(query string) {
fss.Start()
}
func (fss *FileSystemScorer) Change_filter(filter Filter) {
if fss.filter.Equal(filter) {
return
}
fss.keep_going.Store(false)
if fss.current_worker_wait != nil {
fss.current_worker_wait.Wait()
}
fss.lock()
fss.filter = filter
fss.sorted_results.Clear()
fss.unlock()
fss.Start()
}
func (fss *FileSystemScorer) worker(on_results chan bool, worker_wait *sync.WaitGroup) {
defer func() {
fss.is_complete.Store(true)
@@ -353,9 +370,18 @@ func (fss *FileSystemScorer) worker(on_results chan bool, worker_wait *sync.Wait
}
}
} else {
rp = make([]*ResultItem, len(results))
for i := range len(rp) {
rp[i] = &results[i]
if fss.filter.Match == nil {
rp = make([]*ResultItem, len(results))
for i := range len(rp) {
rp[i] = &results[i]
}
} else {
rp = make([]*ResultItem, 0, len(results))
for i, r := range results {
if r.ftype.IsDir() || fss.filter.Match(filepath.Base(r.text)) {
rp = append(rp, &results[i])
}
}
}
}
if len(rp) > 0 {
@@ -452,7 +478,7 @@ func (m *ResultManager) on_results(err error, is_finished bool) {
}
}
func (m *ResultManager) set_root_dir(root_dir string) {
func (m *ResultManager) set_root_dir(root_dir string, filter Filter) {
var err error
if root_dir == "" || root_dir == "." {
if root_dir, err = os.Getwd(); err != nil {
@@ -466,25 +492,38 @@ func (m *ResultManager) set_root_dir(root_dir string) {
if m.scorer != nil {
m.scorer.Cancel()
}
m.scorer = NewFileSystemScorer(root_dir, "", m.settings.OnlyDirs(), m.on_results)
_ = 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)
m.mutex.Lock()
m.last_wakeup_at = time.Time{}
m.mutex.Unlock()
m.scorer.Start()
}
func (m *ResultManager) set_query(query string) {
func (m *ResultManager) set_query(query string, filter Filter) {
m.mutex.Lock()
m.last_wakeup_at = time.Time{}
m.mutex.Unlock()
if m.scorer == nil {
m.scorer = NewFileSystemScorer(".", "", m.settings.OnlyDirs(), m.on_results)
m.scorer = NewFileSystemScorer(".", "", filter, m.settings.OnlyDirs(), m.on_results)
m.scorer.Start()
} else {
m.scorer.Change_query(query)
}
}
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 (h *Handler) get_results() (ans *SortedResults, is_complete bool) {
if h.result_manager.scorer == nil {
return