Skip to content

Commit 2e92d61

Browse files
committed
Implement metadata based previews in choose-files
1 parent 6347ea0 commit 2e92d61

File tree

7 files changed

+272
-8
lines changed

7 files changed

+272
-8
lines changed

docs/kittens/choose-files.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The choose-files kitten is designed to allow you to select files, very fast,
1212
with just a few key strokes. It operates like `fzf
1313
<https://github.com/junegunn/fzf/>`__ and similar fuzzy finders, except that
1414
it is specialised for finding files. As such it supports features such as
15-
filtering by file type, file type icons, content previews (coming soon) and
15+
filtering by file type, file type icons, content previews and
1616
so on, out of the box. It can be used as a drop in (but much more efficient and
1717
keyboard friendly) replacement for the :guilabel:`File open and save`
1818
dialog boxes common to GUI programs. On Linux, with the help of the

kittens/choose_files/main.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ func (m Mode) WindowTitle() string {
9797
}
9898

9999
type render_state struct {
100-
num_matches, num_of_slots, num_before, num_per_column, num_columns, num_shown int
101-
first_idx CollectionIndex
100+
num_matches, num_of_slots, num_before, num_per_column, num_columns, num_shown, preview_width int
101+
first_idx CollectionIndex
102102
}
103103

104104
type State struct {
@@ -116,6 +116,7 @@ type State struct {
116116
filter_map map[string]Filter
117117
filter_names []string
118118
show_hidden bool
119+
show_preview bool
119120
respect_ignores bool
120121
sort_by_last_modified bool
121122
global_ignores ignorefiles.IgnoreFile
@@ -131,6 +132,7 @@ type State struct {
131132

132133
func (s State) DisplayTitle() bool { return s.display_title }
133134
func (s State) ShowHidden() bool { return s.show_hidden }
135+
func (s State) ShowPreview() bool { return s.show_preview }
134136
func (s State) RespectIgnores() bool { return s.respect_ignores }
135137
func (s State) SortByLastModified() bool { return s.sort_by_last_modified }
136138
func (s State) GlobalIgnores() ignorefiles.IgnoreFile { return s.global_ignores }
@@ -209,6 +211,7 @@ type Handler struct {
209211
shortcut_tracker config.ShortcutTracker
210212
msg_printer *message.Printer
211213
spinner *tui.Spinner
214+
preview_manager *PreviewManager
212215
}
213216

214217
func (h *Handler) draw_screen() (err error) {
@@ -482,6 +485,9 @@ func (h *Handler) dispatch_action(name, args string) (err error) {
482485
}
483486
case "toggle":
484487
switch args {
488+
case "preview":
489+
h.state.show_preview = !h.state.show_preview
490+
return h.draw_screen()
485491
case "dotfiles":
486492
h.state.show_hidden = !h.state.show_hidden
487493
h.result_manager.set_show_hidden()
@@ -577,6 +583,7 @@ func (h *Handler) OnText(text string, from_key_event, in_bracketed_paste bool) (
577583

578584
type CachedValues struct {
579585
Show_hidden bool `json:"show_hidden"`
586+
Hide_preview bool `json:"hide_preview"`
580587
Respect_ignores bool `json:"respect_ignores"`
581588
Sort_by_last_modified bool `json:"sort_by_last_modified"`
582589
}
@@ -593,7 +600,7 @@ var cached_values = sync.OnceValue(func() *CachedValues {
593600
})
594601

595602
func (s State) save_cached_values() {
596-
c := CachedValues{Show_hidden: s.show_hidden, Respect_ignores: s.respect_ignores, Sort_by_last_modified: s.sort_by_last_modified}
603+
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}
597604
fname := filepath.Join(utils.CacheDir(), cache_filename)
598605
if data, err := json.Marshal(c); err == nil {
599606
_ = os.WriteFile(fname, data, 0600)
@@ -661,6 +668,8 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error)
661668
h.state.sort_by_last_modified = false
662669
h.state.respect_ignores = true
663670
h.state.show_hidden = false
671+
h.state.show_preview = true
672+
664673
switch conf.Show_hidden {
665674
case Show_hidden_true, Show_hidden_y, Show_hidden_yes:
666675
h.state.show_hidden = true
@@ -685,6 +694,15 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error)
685694
case Sort_by_last_modified_last:
686695
h.state.sort_by_last_modified = cached_values().Sort_by_last_modified
687696
}
697+
switch conf.Show_preview {
698+
case Show_preview_true, Show_preview_y, Show_preview_yes:
699+
h.state.show_preview = true
700+
case Show_preview_false, Show_preview_n, Show_preview_no:
701+
h.state.show_preview = false
702+
case Show_preview_last:
703+
h.state.show_preview = !cached_values().Hide_preview
704+
}
705+
688706
h.state.global_ignores = ignorefiles.NewGitignore()
689707
if err = h.state.global_ignores.LoadLines(conf.Ignore...); err != nil {
690708
return err
@@ -753,6 +771,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
753771
return 1, err
754772
}
755773
handler.result_manager = NewResultManager(handler.err_chan, &handler.state, lp.WakeupMainThread)
774+
handler.preview_manager = NewPreviewManager(handler.err_chan, &handler.state, lp.WakeupMainThread)
756775
switch len(args) {
757776
case 0:
758777
if default_cwd, err = os.Getwd(); err != nil {

kittens/choose_files/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
Can be specified multiple times to use multiple patterns. Note that every pattern
4141
has to be checked against every file, so use sparingly.
4242
''')
43+
44+
opt('show_preview', 'last', choices=('last', 'yes', 'y', 'true', 'no', 'n', 'false'), long_text='''
45+
Whether to show a preview of the current file/directory. The default value of :code:`last` means remember the last
46+
used value. This setting can be toggled withing the program.''')
4347
egr() # }}}
4448

4549
agr('shortcuts', 'Keyboard shortcuts') # {{{
@@ -81,6 +85,7 @@
8185
map('Toggle showing dotfiles', 'toggle_dotfiles alt+h toggle dotfiles')
8286
map('Toggle showing ignored files', 'toggle_ignorefiles alt+i toggle ignorefiles')
8387
map('Toggle sorting by dates', 'toggle_sort_by_dates alt+d toggle sort_by_dates')
88+
map('Toggle showing preview', 'toggle_preview alt+p toggle preview')
8489

8590
egr() # }}}
8691

kittens/choose_files/preview.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package choose_files
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
"slices"
9+
"strings"
10+
"sync"
11+
12+
"github.com/kovidgoyal/kitty/tools/icons"
13+
"github.com/kovidgoyal/kitty/tools/utils"
14+
"github.com/kovidgoyal/kitty/tools/utils/humanize"
15+
"github.com/kovidgoyal/kitty/tools/utils/style"
16+
"github.com/kovidgoyal/kitty/tools/wcswidth"
17+
)
18+
19+
var _ = fmt.Print
20+
21+
type Preview interface {
22+
Render(h *Handler, x, y, width, height int)
23+
}
24+
25+
type PreviewManager struct {
26+
report_errors chan error
27+
settings Settings
28+
WakeupMainThread func() bool
29+
cache map[string]Preview
30+
lock sync.Mutex
31+
}
32+
33+
func NewPreviewManager(err_chan chan error, settings Settings, WakeupMainThread func() bool) *PreviewManager {
34+
return &PreviewManager{
35+
report_errors: err_chan, settings: settings, WakeupMainThread: WakeupMainThread,
36+
cache: make(map[string]Preview),
37+
}
38+
}
39+
40+
func (pm *PreviewManager) cached_preview(path string) Preview {
41+
pm.lock.Lock()
42+
defer pm.lock.Unlock()
43+
return pm.cache[path]
44+
}
45+
46+
func (pm *PreviewManager) set_cached_preview(path string, val Preview) {
47+
pm.lock.Lock()
48+
defer pm.lock.Unlock()
49+
pm.cache[path] = val
50+
}
51+
52+
func (h *Handler) render_wrapped_text_in_region(text string, x, y, width, height int, centered bool) int {
53+
lines := style.WrapTextAsLines(text, width, style.WrapOptions{})
54+
for i, line := range lines {
55+
extra := 0
56+
if centered {
57+
extra = max(0, width-wcswidth.Stringwidth(line)) / 2
58+
}
59+
h.lp.MoveCursorTo(x+extra, y+i)
60+
h.lp.QueueWriteString(line)
61+
if i >= height {
62+
break
63+
}
64+
}
65+
return len(lines)
66+
}
67+
68+
type MessagePreview struct {
69+
title string
70+
msg string
71+
trailers []string
72+
}
73+
74+
func (p MessagePreview) Render(h *Handler, x, y, width, height int) {
75+
offset := 0
76+
if p.title != "" {
77+
offset += h.render_wrapped_text_in_region(p.title, x, y, width, height, true)
78+
}
79+
offset += h.render_wrapped_text_in_region(p.msg, x, y+offset, width, height-offset, false)
80+
limit := height - offset
81+
if limit > 1 {
82+
for i, line := range p.trailers {
83+
text := wcswidth.TruncateToVisualLength(line, width-1)
84+
if len(text) < len(line) {
85+
text += "…"
86+
}
87+
h.lp.MoveCursorTo(x, y+offset+i-1)
88+
if i >= limit {
89+
h.lp.QueueWriteString("…")
90+
break
91+
}
92+
h.lp.QueueWriteString(text)
93+
}
94+
}
95+
}
96+
97+
func NewErrorPreview(err error) Preview {
98+
sctx := style.Context{AllowEscapeCodes: true}
99+
text := fmt.Sprintf("%s: %s", sctx.SprintFunc("fg=red")("Error"), err)
100+
return &MessagePreview{msg: text}
101+
}
102+
103+
func write_file_metadata(abspath string, metadata fs.FileInfo, entries []fs.DirEntry) (header string, trailers []string) {
104+
buf := strings.Builder{}
105+
buf.Grow(4096)
106+
add := func(key, val string) { fmt.Fprintf(&buf, "%s: %s\n", key, val) }
107+
ftype := metadata.Mode().Type()
108+
const file_icon = " "
109+
switch ftype {
110+
case 0:
111+
add("Size", humanize.Bytes(uint64(metadata.Size())))
112+
case fs.ModeSymlink:
113+
if tgt, err := os.Readlink(abspath); err == nil {
114+
add("Target", tgt)
115+
} else {
116+
add("Target", err.Error())
117+
}
118+
case fs.ModeDir:
119+
num_files, num_dirs := 0, 0
120+
for _, e := range entries {
121+
if e.IsDir() {
122+
num_dirs++
123+
} else {
124+
num_files++
125+
}
126+
}
127+
add("Children", fmt.Sprintf("%d %s %d %s", num_dirs, icons.IconForFileWithMode("dir", fs.ModeDir, false), num_files, file_icon))
128+
}
129+
add("Modified", humanize.Time(metadata.ModTime()))
130+
add("Mode", metadata.Mode().String())
131+
if len(entries) > 0 {
132+
type entry struct {
133+
lname string
134+
ftype fs.FileMode
135+
}
136+
type_map := make(map[string]entry, len(entries))
137+
for _, e := range entries {
138+
type_map[e.Name()] = entry{strings.ToLower(e.Name()), e.Type()}
139+
}
140+
names := utils.Map(func(e fs.DirEntry) string { return e.Name() }, entries)
141+
slices.SortFunc(names, func(a, b string) int { return strings.Compare(type_map[a].lname, type_map[b].lname) })
142+
fmt.Fprintln(&buf, "Contents:")
143+
for _, n := range names {
144+
trailers = append(trailers, icons.IconForFileWithMode(n, type_map[n].ftype, false)+" "+n)
145+
}
146+
}
147+
return buf.String(), trailers
148+
}
149+
150+
func NewDirectoryPreview(abspath string, metadata fs.FileInfo) Preview {
151+
entries, err := os.ReadDir(abspath)
152+
if err != nil {
153+
return NewErrorPreview(fmt.Errorf("failed to read the directory %s with error: %w", abspath, err))
154+
}
155+
title := icons.IconForFileWithMode("dir", fs.ModeDir, false) + " Directory\n"
156+
header, extra := write_file_metadata(abspath, metadata, entries)
157+
return &MessagePreview{title: title, msg: header, trailers: extra}
158+
}
159+
160+
func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) Preview {
161+
title := icons.IconForFileWithMode(filepath.Base(abspath), metadata.Mode().Type(), false) + " File"
162+
h, t := write_file_metadata(abspath, metadata, nil)
163+
return &MessagePreview{title: title, msg: h, trailers: t}
164+
}
165+
166+
func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Preview) {
167+
if ans = pm.cached_preview(abspath); ans != nil {
168+
return ans
169+
}
170+
defer func() { pm.set_cached_preview(abspath, ans) }()
171+
s, err := os.Lstat(abspath)
172+
if err != nil {
173+
return NewErrorPreview(err)
174+
}
175+
if s.IsDir() {
176+
return NewDirectoryPreview(abspath, s)
177+
}
178+
if ftype&fs.ModeSymlink != 0 && ftype&SymlinkToDir != 0 {
179+
s, err = os.Stat(abspath)
180+
if err != nil {
181+
return NewErrorPreview(err)
182+
}
183+
return NewDirectoryPreview(abspath, s)
184+
}
185+
return NewFileMetadataPreview(abspath, s)
186+
}
187+
188+
func (h *Handler) draw_preview_content(x, y, width, height int) {
189+
matches, _ := h.get_results()
190+
r := matches.At(h.state.CurrentIndex())
191+
if r == nil {
192+
h.render_wrapped_text_in_region("No preview available", x, y, width, height, false)
193+
return
194+
}
195+
abspath := filepath.Join(h.state.CurrentDir(), r.text)
196+
if p := h.preview_manager.preview_for(abspath, r.ftype); p == nil {
197+
h.render_wrapped_text_in_region("No preview available", x, y, width, height, false)
198+
} else {
199+
p.Render(h, x, y, width, height)
200+
}
201+
}

0 commit comments

Comments
 (0)