From 2e92d610d546aec0e98e5558a2a1b5a88ea72f8b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 20 Jul 2025 09:31:17 +0530 Subject: [PATCH] Implement metadata based previews in choose-files --- docs/kittens/choose-files.rst | 2 +- kittens/choose_files/main.go | 25 +++- kittens/choose_files/main.py | 5 + kittens/choose_files/preview.go | 201 +++++++++++++++++++++++++++++ kittens/choose_files/results.go | 43 +++++- kittens/choose_files/search-bar.go | 3 + kitty_tests/completion.py | 1 + 7 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 kittens/choose_files/preview.go diff --git a/docs/kittens/choose-files.rst b/docs/kittens/choose-files.rst index 241d87513..fdf4cc91a 100644 --- a/docs/kittens/choose-files.rst +++ b/docs/kittens/choose-files.rst @@ -12,7 +12,7 @@ The choose-files kitten is designed to allow you to select files, very fast, with just a few key strokes. It operates like `fzf `__ and similar fuzzy finders, except that it is specialised for finding files. As such it supports features such as -filtering by file type, file type icons, content previews (coming soon) and +filtering by file type, file type icons, content previews and so on, out of the box. It can be used as a drop in (but much more efficient and keyboard friendly) replacement for the :guilabel:`File open and save` dialog boxes common to GUI programs. On Linux, with the help of the diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index d3a465921..459de19b2 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -97,8 +97,8 @@ func (m Mode) WindowTitle() string { } type render_state struct { - num_matches, num_of_slots, num_before, num_per_column, num_columns, num_shown int - first_idx CollectionIndex + num_matches, num_of_slots, num_before, num_per_column, num_columns, num_shown, preview_width int + first_idx CollectionIndex } type State struct { @@ -116,6 +116,7 @@ type State struct { filter_map map[string]Filter filter_names []string show_hidden bool + show_preview bool respect_ignores bool sort_by_last_modified bool global_ignores ignorefiles.IgnoreFile @@ -131,6 +132,7 @@ type State struct { func (s State) DisplayTitle() bool { return s.display_title } func (s State) ShowHidden() bool { return s.show_hidden } +func (s State) ShowPreview() bool { return s.show_preview } 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 } @@ -209,6 +211,7 @@ type Handler struct { shortcut_tracker config.ShortcutTracker msg_printer *message.Printer spinner *tui.Spinner + preview_manager *PreviewManager } func (h *Handler) draw_screen() (err error) { @@ -482,6 +485,9 @@ func (h *Handler) dispatch_action(name, args string) (err error) { } case "toggle": switch args { + case "preview": + h.state.show_preview = !h.state.show_preview + return h.draw_screen() case "dotfiles": h.state.show_hidden = !h.state.show_hidden h.result_manager.set_show_hidden() @@ -577,6 +583,7 @@ func (h *Handler) OnText(text string, from_key_event, in_bracketed_paste bool) ( type CachedValues struct { Show_hidden bool `json:"show_hidden"` + Hide_preview bool `json:"hide_preview"` Respect_ignores bool `json:"respect_ignores"` Sort_by_last_modified bool `json:"sort_by_last_modified"` } @@ -593,7 +600,7 @@ var cached_values = sync.OnceValue(func() *CachedValues { }) 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} + c := CachedValues{Show_hidden: s.show_hidden, Respect_ignores: s.respect_ignores, Sort_by_last_modified: s.sort_by_last_modified, Hide_preview: !s.show_preview} fname := filepath.Join(utils.CacheDir(), cache_filename) if data, err := json.Marshal(c); err == nil { _ = os.WriteFile(fname, data, 0600) @@ -661,6 +668,8 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) h.state.sort_by_last_modified = false h.state.respect_ignores = true h.state.show_hidden = false + h.state.show_preview = true + switch conf.Show_hidden { case Show_hidden_true, Show_hidden_y, Show_hidden_yes: h.state.show_hidden = true @@ -685,6 +694,15 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) case Sort_by_last_modified_last: h.state.sort_by_last_modified = cached_values().Sort_by_last_modified } + switch conf.Show_preview { + case Show_preview_true, Show_preview_y, Show_preview_yes: + h.state.show_preview = true + case Show_preview_false, Show_preview_n, Show_preview_no: + h.state.show_preview = false + case Show_preview_last: + h.state.show_preview = !cached_values().Hide_preview + } + h.state.global_ignores = ignorefiles.NewGitignore() if err = h.state.global_ignores.LoadLines(conf.Ignore...); err != nil { return err @@ -753,6 +771,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { return 1, err } handler.result_manager = NewResultManager(handler.err_chan, &handler.state, lp.WakeupMainThread) + handler.preview_manager = NewPreviewManager(handler.err_chan, &handler.state, lp.WakeupMainThread) switch len(args) { case 0: if default_cwd, err = os.Getwd(); err != nil { diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 28ebac7ee..1d8b402fc 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -40,6 +40,10 @@ Can be specified multiple times to use multiple patterns. Note that every pattern has to be checked against every file, so use sparingly. ''') + +opt('show_preview', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text=''' +Whether to show a preview of the current file/directory. The default value of :code:`last` means remember the last +used value. This setting can be toggled withing the program.''') egr() # }}} agr('shortcuts', 'Keyboard shortcuts') # {{{ @@ -81,6 +85,7 @@ map('Toggle showing dotfiles', 'toggle_dotfiles alt+h toggle dotfiles') map('Toggle showing ignored files', 'toggle_ignorefiles alt+i toggle ignorefiles') map('Toggle sorting by dates', 'toggle_sort_by_dates alt+d toggle sort_by_dates') +map('Toggle showing preview', 'toggle_preview alt+p toggle preview') egr() # }}} diff --git a/kittens/choose_files/preview.go b/kittens/choose_files/preview.go new file mode 100644 index 000000000..3b90cc3db --- /dev/null +++ b/kittens/choose_files/preview.go @@ -0,0 +1,201 @@ +package choose_files + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/kovidgoyal/kitty/tools/icons" + "github.com/kovidgoyal/kitty/tools/utils" + "github.com/kovidgoyal/kitty/tools/utils/humanize" + "github.com/kovidgoyal/kitty/tools/utils/style" + "github.com/kovidgoyal/kitty/tools/wcswidth" +) + +var _ = fmt.Print + +type Preview interface { + Render(h *Handler, x, y, width, height int) +} + +type PreviewManager struct { + report_errors chan error + settings Settings + WakeupMainThread func() bool + cache map[string]Preview + lock sync.Mutex +} + +func NewPreviewManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) *PreviewManager { + return &PreviewManager{ + report_errors: err_chan, settings: settings, WakeupMainThread: WakeupMainThread, + cache: make(map[string]Preview), + } +} + +func (pm *PreviewManager) cached_preview(path string) Preview { + pm.lock.Lock() + defer pm.lock.Unlock() + return pm.cache[path] +} + +func (pm *PreviewManager) set_cached_preview(path string, val Preview) { + pm.lock.Lock() + defer pm.lock.Unlock() + pm.cache[path] = val +} + +func (h *Handler) render_wrapped_text_in_region(text string, x, y, width, height int, centered bool) int { + lines := style.WrapTextAsLines(text, width, style.WrapOptions{}) + for i, line := range lines { + extra := 0 + if centered { + extra = max(0, width-wcswidth.Stringwidth(line)) / 2 + } + h.lp.MoveCursorTo(x+extra, y+i) + h.lp.QueueWriteString(line) + if i >= height { + break + } + } + return len(lines) +} + +type MessagePreview struct { + title string + msg string + trailers []string +} + +func (p MessagePreview) Render(h *Handler, x, y, width, height int) { + offset := 0 + if p.title != "" { + offset += h.render_wrapped_text_in_region(p.title, x, y, width, height, true) + } + offset += h.render_wrapped_text_in_region(p.msg, x, y+offset, width, height-offset, false) + limit := height - offset + if limit > 1 { + for i, line := range p.trailers { + text := wcswidth.TruncateToVisualLength(line, width-1) + if len(text) < len(line) { + text += "…" + } + h.lp.MoveCursorTo(x, y+offset+i-1) + if i >= limit { + h.lp.QueueWriteString("…") + break + } + h.lp.QueueWriteString(text) + } + } +} + +func NewErrorPreview(err error) Preview { + sctx := style.Context{AllowEscapeCodes: true} + text := fmt.Sprintf("%s: %s", sctx.SprintFunc("fg=red")("Error"), err) + return &MessagePreview{msg: text} +} + +func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirEntry) (header string, trailers []string) { + buf := strings.Builder{} + buf.Grow(4096) + add := func(key, val string) { fmt.Fprintf(&buf, "%s: %s\n", key, val) } + ftype := metadata.Mode().Type() + const file_icon = " " + switch ftype { + case 0: + add("Size", humanize.Bytes(uint64(metadata.Size()))) + case fs.ModeSymlink: + if tgt, err := os.Readlink(abspath); err == nil { + add("Target", tgt) + } else { + add("Target", err.Error()) + } + case fs.ModeDir: + num_files, num_dirs := 0, 0 + for _, e := range entries { + if e.IsDir() { + num_dirs++ + } else { + num_files++ + } + } + add("Children", fmt.Sprintf("%d %s %d %s", num_dirs, icons.IconForFileWithMode("dir", fs.ModeDir, false), num_files, file_icon)) + } + add("Modified", humanize.Time(metadata.ModTime())) + add("Mode", metadata.Mode().String()) + if len(entries) > 0 { + type entry struct { + lname string + ftype fs.FileMode + } + type_map := make(map[string]entry, len(entries)) + for _, e := range entries { + type_map[e.Name()] = entry{strings.ToLower(e.Name()), e.Type()} + } + names := utils.Map(func(e fs.DirEntry) string { return e.Name() }, entries) + slices.SortFunc(names, func(a, b string) int { return strings.Compare(type_map[a].lname, type_map[b].lname) }) + fmt.Fprintln(&buf, "Contents:") + for _, n := range names { + trailers = append(trailers, icons.IconForFileWithMode(n, type_map[n].ftype, false)+" "+n) + } + } + return buf.String(), trailers +} + +func NewDirectoryPreview(abspath string, metadata fs.FileInfo) Preview { + entries, err := os.ReadDir(abspath) + if err != nil { + return NewErrorPreview(fmt.Errorf("failed to read the directory %s with error: %w", abspath, err)) + } + title := icons.IconForFileWithMode("dir", fs.ModeDir, false) + " Directory\n" + header, extra := write_file_metadata(abspath, metadata, entries) + return &MessagePreview{title: title, msg: header, trailers: extra} +} + +func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) Preview { + title := icons.IconForFileWithMode(filepath.Base(abspath), metadata.Mode().Type(), false) + " File" + h, t := write_file_metadata(abspath, metadata, nil) + return &MessagePreview{title: title, msg: h, trailers: t} +} + +func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Preview) { + if ans = pm.cached_preview(abspath); ans != nil { + return ans + } + defer func() { pm.set_cached_preview(abspath, ans) }() + s, err := os.Lstat(abspath) + if err != nil { + return NewErrorPreview(err) + } + if s.IsDir() { + return NewDirectoryPreview(abspath, s) + } + if ftype&fs.ModeSymlink != 0 && ftype&SymlinkToDir != 0 { + s, err = os.Stat(abspath) + if err != nil { + return NewErrorPreview(err) + } + return NewDirectoryPreview(abspath, s) + } + return NewFileMetadataPreview(abspath, s) +} + +func (h *Handler) draw_preview_content(x, y, width, height int) { + matches, _ := h.get_results() + r := matches.At(h.state.CurrentIndex()) + if r == nil { + h.render_wrapped_text_in_region("No preview available", x, y, width, height, false) + return + } + abspath := filepath.Join(h.state.CurrentDir(), r.text) + if p := h.preview_manager.preview_for(abspath, r.ftype); p == nil { + h.render_wrapped_text_in_region("No preview available", x, y, width, height, false) + } else { + p.Render(h, x, y, width, height) + } +} diff --git a/kittens/choose_files/results.go b/kittens/choose_files/results.go index e3773d221..c7a69b4d5 100644 --- a/kittens/choose_files/results.go +++ b/kittens/choose_files/results.go @@ -208,8 +208,22 @@ func (h *Handler) draw_column_of_matches(matches ResultsType, current_idx int, x } } -func (h *Handler) draw_list_of_results(matches *SortedResults, y, height int) (num_cols, num_shown int) { +func (h *Handler) draw_list_of_results(matches *SortedResults, y, height int) (num_cols, num_shown, preview_width int) { + const BASE_COL_WIDTH = 40 available_width := h.screen_size.width - 2 + show_preview := h.state.ShowPreview() + if show_preview && available_width < BASE_COL_WIDTH+30 { + show_preview = false + } + if show_preview { + switch { + case available_width < BASE_COL_WIDTH*2: + preview_width = max(30, available_width/2) + default: + preview_width = BASE_COL_WIDTH + } + available_width -= preview_width + } col_width := available_width num_cols = 1 calc_num_cols := func(num_matches int) int { @@ -217,7 +231,7 @@ func (h *Handler) draw_list_of_results(matches *SortedResults, y, height int) (n return 0 } if num_matches > height { - col_width = 40 + col_width = BASE_COL_WIDTH num_cols = available_width / col_width for num_cols > 0 && height*(num_cols-1) >= num_matches { num_cols-- @@ -239,7 +253,7 @@ func (h *Handler) draw_list_of_results(matches *SortedResults, y, height int) (n num_shown += len(col) x += col_width } - return len(columns), num_shown + return len(columns), num_shown, preview_width } func (h *Handler) draw_num_of_matches(num_shown, y int, in_progress bool) { @@ -274,6 +288,23 @@ func (h *Handler) draw_num_of_matches(num_shown, y int, in_progress bool) { } } +func (h *Handler) draw_preview(y int) { + x := h.screen_size.width - h.state.last_render.preview_width + height := h.state.last_render.num_of_slots + buf := strings.Builder{} + buf.Grow(16 * height) + buf.WriteString(fmt.Sprintf(loop.MoveCursorToTemplate, y-1, x)) + buf.WriteString("┬") + for i := range height { + buf.WriteString(fmt.Sprintf(loop.MoveCursorToTemplate, y+i, x)) + buf.WriteString("│") + } + buf.WriteString(fmt.Sprintf(loop.MoveCursorToTemplate, y+height, x)) + buf.WriteString("┴") + h.lp.QueueWriteString(buf.String()) + h.draw_preview_content(x+1, y, h.state.last_render.preview_width-1, h.state.last_render.num_of_slots) +} + func (h *Handler) draw_results(y, bottom_margin int, matches *SortedResults, in_progress bool) (height int) { height = h.screen_size.height - y - bottom_margin h.lp.MoveCursorTo(1, 1+y) @@ -286,15 +317,19 @@ func (h *Handler) draw_results(y, bottom_margin int, matches *SortedResults, in_ num_cols := 0 num := matches.Len() num_shown := 0 + h.state.last_render.preview_width = 0 switch num { case 0: h.draw_no_matches_message(in_progress) default: - num_cols, num_shown = h.draw_list_of_results(matches, y, h.state.last_render.num_of_slots) + num_cols, num_shown, h.state.last_render.preview_width = h.draw_list_of_results(matches, y, h.state.last_render.num_of_slots) } h.state.last_render.num_matches = num h.state.last_render.num_shown = num_shown h.draw_num_of_matches(h.state.last_render.num_of_slots*num_cols, y+height-2, in_progress) + if h.state.last_render.preview_width > 0 { + h.draw_preview(y) + } return } diff --git a/kittens/choose_files/search-bar.go b/kittens/choose_files/search-bar.go index cee44effb..d64f882b6 100644 --- a/kittens/choose_files/search-bar.go +++ b/kittens/choose_files/search-bar.go @@ -80,6 +80,9 @@ func (h *Handler) draw_controls(y int) (max_width int) { h.state.respect_ignores = !h.state.respect_ignores h.result_manager.set_respect_ignores() }) + add_control(" ", utils.IfElse(h.state.ShowPreview(), "hide preview", "show preview"), func() { + h.state.show_preview = !h.state.show_preview + }) add_control(utils.IfElse(h.state.SortByLastModified(), " ", " "), utils.IfElse(h.state.SortByLastModified(), "sort names", "sort dates"), func() { h.state.sort_by_last_modified = !h.state.sort_by_last_modified h.result_manager.set_sort_by_last_modified() diff --git a/kitty_tests/completion.py b/kitty_tests/completion.py index 89e6d6147..d0d40e2fa 100644 --- a/kitty_tests/completion.py +++ b/kitty_tests/completion.py @@ -85,6 +85,7 @@ def run_tool(): env['PATH'] = os.path.join(tdir, 'bin') env['HOME'] = os.path.join(tdir, 'sub') env['KITTY_CONFIG_DIRECTORY'] = os.path.join(tdir, 'sub') + print(1111111, all_argv) cp = subprocess.run( [kitten(), '__complete__', 'json'], check=True, stdout=subprocess.PIPE, cwd=tdir, input=json.dumps(all_argv).encode(), env=env