Implement metadata based previews in choose-files

This commit is contained in:
Kovid Goyal
2025-07-20 09:31:17 +05:30
parent 6347ea0210
commit 2e92d610d5
7 changed files with 272 additions and 8 deletions

View File

@@ -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
<https://github.com/junegunn/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

View File

@@ -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 {

View File

@@ -40,6 +40,10 @@ Anchored patterns match with respect to whatever directory is currently being di
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('Previous filter', 'prev_filter alt+f -1')
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() # }}}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -85,6 +85,7 @@ def completion(self: TestCompletion, tdir: str):
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