choose files: Add arbitrary command based previews

Needs testing. Someday.
This commit is contained in:
Kovid Goyal 2025-12-05 19:54:21 +05:30
parent 7075f71da4
commit 772805b8ec
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
4 changed files with 168 additions and 1 deletions

View file

@ -0,0 +1,100 @@
package choose_files
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"github.com/kovidgoyal/kitty/tools/icons"
"github.com/kovidgoyal/kitty/tools/utils/humanize"
"github.com/kovidgoyal/kitty/tools/utils/images"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
const CMD_METADATA_KEY = "cmd-metadata.json"
type cmd_renderer struct {
cmdline []string
}
func (c cmd_renderer) String() string {
return c.cmdline[0]
}
type CmdResult struct {
Lines []string `json:"lines"`
Image string `json:"image"`
TitleExtra string `json:"title_extra"`
}
func (c cmd_renderer) Render(path string) (m map[string][]byte, mi metadata, img *images.ImageData, err error) {
cmdline := append(c.cmdline, path)
cmd := exec.Command(cmdline[0], cmdline[1:]...)
cmd.Stdin = nil
cmd.SysProcAttr = &unix.SysProcAttr{Setsid: true}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err = cmd.Run(); err != nil {
err = fmt.Errorf("failed to run %v to read metadata from %s with error: %w and stderr: %s", c.cmdline, path, err, stderr.String())
return
}
var md CmdResult
if err = json.Unmarshal(stdout.Bytes(), &md); err != nil {
err = fmt.Errorf("could not decode JSON response from %v for %s: %w", c.cmdline, path, err)
}
if md.Image != "" {
var ip ImagePreviewRenderer
if m, mi, img, err = ip.Render(md.Image); err != nil {
return
}
}
mi.custom = &md
return
}
func (c cmd_renderer) Unmarshall(m map[string]string) (any, error) {
data, err := os.ReadFile(m[CMD_METADATA_KEY])
if err != nil {
return nil, err
}
var ans CmdResult
if err = json.Unmarshal(data, &ans); err != nil {
return nil, err
}
return &ans, nil
}
func (c cmd_renderer) ShowMetadata(h *Handler, s ShowData) (offset int) {
w := func(text string, center bool) {
if s.height > offset {
offset += h.render_wrapped_text_in_region(text, s.x, s.y+offset, s.width, s.height-offset, center)
}
}
ext := filepath.Ext(s.abspath)
r := s.custom_metadata.custom.(*CmdResult)
text := fmt.Sprintf("%s: %s%s", ext, humanize.Bytes(uint64(s.metadata.Size())), r.TitleExtra)
icon := icons.IconForPath(s.abspath)
w(icon+" "+text, true)
for _, line := range r.Lines {
w(line, false)
}
return
}
func NewCmdPreview(
abspath string, metadata fs.FileInfo, opts Settings, WakeupMainThread func() bool, p previewer,
) Preview {
c := cmd_renderer{p.cmdline}
if ans, err := NewImagePreview(abspath, metadata, opts, WakeupMainThread, c); err == nil {
return ans
} else {
return NewErrorPreview(err)
}
}

View file

@ -272,6 +272,38 @@ func (h *Handler) draw_screen() (err error) {
return
}
type previewer struct {
cmdline []string
pattern string
use_mimetype bool
}
func (p previewer) matches(fname, mt string) bool {
q := utils.IfElse(p.use_mimetype, mt, fname)
matched, err := filepath.Match(p.pattern, q)
return matched && err == nil
}
var previewers []previewer
func load_previewers(cfg *Config) (err error) {
for _, spec := range cfg.Previewer {
parts, err := shlex.Split(spec)
if err != nil {
return fmt.Errorf("invalid previewer specification: %#v with error: %w", spec, err)
}
if len(parts) < 2 {
return fmt.Errorf("invalid previewer specification: %#v with error: no command specified", spec)
}
prefix, pattern, found := strings.Cut(parts[0], ":")
if !found {
return fmt.Errorf("invalid previewer specification: %#v with error: no prefix in pattern specification", spec)
}
previewers = append(previewers, previewer{parts[1:], pattern, prefix == "mime"})
}
return
}
func load_config(opts *Options) (ans *Config, err error) {
ans = NewConfig()
p := config.ConfigParser{LineHandler: ans.Parse}
@ -855,6 +887,9 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
if err != nil {
return 1, err
}
if err = load_previewers(conf); err != nil {
return 1, err
}
lp, err := loop.New()
if err != nil {
return 1, err

View file

@ -85,7 +85,33 @@
otherwise it will not affect already cached previews.
''')
opt('+previewer', '', long_text='''
Specify an arbitrary program based preview generator. The syntax is::
pattern program arguments...
Here, pattern can be used to match file names or mimetypes. For example:
:code:`name:*.doc` matches files with the extension :code:`.doc`. Similarly,
:code:`mime:image/*` matches all image files. :code:`program` can be any
executable program in PATH. It will be run with the supplied arguments. The last argument
will be the path to the file for which a preview must be generated.
Can be specified multiple times to setup different previewers for different types of files.
Note that previewers specified using this option take precedence over the builtin
previewers.
The command must output preview data to STDOUT, in JSON format, of the form::
{
"lines": ["line1", "line2", ...],
"image": "absolute path to generated image preview",
"title_extra": "some text to show on the first line",
}
The lines can contain SGR formatting escape codes and will be displayed as is at the
top of the preview panel. The image is optional and must be in one of the JPEG, PNG, GIF, WEBP, APNG
formats.
''')
egr() # }}}
agr('shortcuts', 'Keyboard shortcuts') # {{{

View file

@ -332,7 +332,13 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr
}
return NewDirectoryPreview(abspath, s)
}
mt := utils.GuessMimeType(filepath.Base(abspath))
fname := filepath.Base(abspath)
mt := utils.GuessMimeType(fname)
for _, q := range previewers {
if q.matches(fname, mt) {
return NewCmdPreview(abspath, s, pm.settings, pm.WakeupMainThread, q)
}
}
const MAX_TEXT_FILE_SIZE = 16 * 1024 * 1024
if s.Size() <= MAX_TEXT_FILE_SIZE && (utils.KnownTextualMimes[mt] || strings.HasPrefix(mt, "text/")) {
ch := make(chan highlighed_data, 2)