Files
kitty/kittens/choose_files/main.go
Kovid Goyal 17c84383f7 Dont store abspath on each result
Optimisation as abspath is calculated in hot path. Instead calculate on
demand.
2025-06-26 19:21:04 +05:30

461 lines
11 KiB
Go

package choose_files
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/config"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/tui/readline"
"github.com/kovidgoyal/kitty/tools/utils"
)
// TODO: Comboboxes, multifile selections, save file name, file/dir modes
var _ = fmt.Print
var debugprintln = tty.DebugPrintln
type Screen int
const (
NORMAL Screen = iota
SAVE_FILE
)
type Mode int
const (
SELECT_SINGLE_FILE Mode = iota
SELECT_MULTIPLE_FILES
SELECT_SAVE_FILE
SELECT_SAVE_DIR
SELECT_SINGLE_DIR
SELECT_MULTIPLE_DIRS
SELECT_SAVE_DIR_FOR_FILES // select a dir for saving one or more pre-sent filenames, must be an existing one
)
func (m Mode) CanSelectNonExistent() bool {
switch m {
case SELECT_SAVE_FILE, SELECT_SAVE_DIR:
return true
}
return false
}
func (m Mode) AllowsMultipleSelection() bool {
switch m {
case SELECT_MULTIPLE_FILES, SELECT_MULTIPLE_DIRS:
return true
}
return false
}
func (m Mode) OnlyDirs() bool {
switch m {
case SELECT_SINGLE_DIR, SELECT_MULTIPLE_DIRS, SELECT_SAVE_DIR, SELECT_SAVE_DIR_FOR_FILES:
return true
}
return false
}
func (m Mode) SelectFiles() bool {
switch m {
case SELECT_SINGLE_FILE, SELECT_MULTIPLE_FILES, SELECT_SAVE_FILE:
return true
}
return false
}
func (m Mode) WindowTitle() string {
switch m {
case SELECT_SINGLE_FILE:
return "Choose an existing file"
case SELECT_MULTIPLE_FILES:
return "Choose one or more existing files"
case SELECT_SAVE_FILE:
return "Choose a file to save"
case SELECT_SAVE_DIR:
return "Choose a directory to save"
case SELECT_SINGLE_DIR:
return "Choose an existing directory"
case SELECT_MULTIPLE_DIRS:
return "Choose one or more directories"
case SELECT_SAVE_DIR_FOR_FILES:
return "Choose a directory to save multiple files in"
}
return ""
}
type State struct {
base_dir string
current_dir string
select_dirs bool
multiselect bool
search_text string
mode Mode
suggested_save_file_name string
window_title string
screen Screen
save_file_cdir string
selections []string
current_idx int
num_of_matches_at_last_render int
num_of_slots_per_column_at_last_render int
}
func (s State) BaseDir() string { return utils.IfElse(s.base_dir == "", default_cwd, s.base_dir) }
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) }
func (s State) SearchText() string { return s.search_text }
func (s State) OnlyDirs() bool { return s.mode.OnlyDirs() }
func (s *State) SetSearchText(val string) {
if s.search_text != val {
s.search_text = val
s.current_idx = 0
}
}
func (s *State) SetCurrentDir(val string) {
if q, err := filepath.Abs(val); err == nil {
val = q
}
if s.CurrentDir() != val {
s.search_text = ""
s.current_idx = 0
s.current_dir = val
}
}
func (s State) CurrentIndex() int { return s.current_idx }
func (s *State) SetCurrentIndex(val int) { s.current_idx = max(0, val) }
func (s State) CurrentDir() string {
return utils.IfElse(s.current_dir == "", s.BaseDir(), s.current_dir)
}
func (s State) WindowTitle() string {
if s.window_title == "" {
return s.mode.WindowTitle()
}
return s.window_title
}
func (s *State) AddSelection(abspath string) bool {
if !slices.Contains(s.selections, abspath) {
s.selections = append(s.selections, abspath)
return true
}
return false
}
func (s *State) ToggleSelection(abspath string) {
before := len(s.selections)
s.selections = slices.DeleteFunc(s.selections, func(x string) bool { return x == abspath })
if len(s.selections) == before {
s.selections = append(s.selections, abspath)
}
}
type ScreenSize struct {
width, height, cell_width, cell_height, width_px, height_px int
}
type Handler struct {
state State
screen_size ScreenSize
result_manager *ResultManager
lp *loop.Loop
rl *readline.Readline
err_chan chan error
}
func (h *Handler) draw_screen() (err error) {
h.lp.StartAtomicUpdate()
defer h.lp.EndAtomicUpdate()
h.lp.ClearScreen()
switch h.state.screen {
case NORMAL:
matches, is_complete := h.get_results()
h.lp.SetWindowTitle(h.state.WindowTitle())
defer func() { // so that the cursor ends up in the right place
h.lp.MoveCursorTo(1, 1)
h.draw_search_bar(0)
}()
y := SEARCH_BAR_HEIGHT
y += h.draw_results(y, 2, matches, !is_complete)
case SAVE_FILE:
err = h.draw_save_file_name_screen()
}
return
}
func load_config(opts *Options) (ans *Config, err error) {
ans = NewConfig()
p := config.ConfigParser{LineHandler: ans.Parse}
err = p.LoadConfig("choose-files.conf", opts.Config, opts.Override)
if err != nil {
return nil, err
}
// ans.KeyboardShortcuts = config.ResolveShortcuts(ans.KeyboardShortcuts)
return ans, nil
}
func (h *Handler) init_sizes(new_size loop.ScreenSize) {
h.screen_size.width = int(new_size.WidthCells)
h.screen_size.height = int(new_size.HeightCells)
h.screen_size.cell_width = int(new_size.CellWidth)
h.screen_size.cell_height = int(new_size.CellHeight)
h.screen_size.width_px = int(new_size.WidthPx)
h.screen_size.height_px = int(new_size.HeightPx)
h.rl.ClearCachedScreenSize()
}
func (h *Handler) OnInitialize() (ans string, err error) {
if sz, err := h.lp.ScreenSize(); err != nil {
return "", err
} else {
h.init_sizes(sz)
}
h.lp.AllowLineWrapping(false)
h.lp.SetCursorShape(loop.BAR_CURSOR, true)
h.lp.StartBracketedPaste()
h.result_manager.set_root_dir(h.state.CurrentDir())
h.draw_screen()
return
}
func (h *Handler) current_abspath() string {
matches, _ := h.get_results()
if len(matches) > 0 {
if idx := h.state.CurrentIndex(); idx < len(matches) {
return filepath.Join(h.state.CurrentDir(), matches[idx].text)
}
}
return ""
}
func (h *Handler) add_selection_if_possible() bool {
m := h.current_abspath()
if m != "" {
return h.state.AddSelection(m)
}
return false
}
func (h *Handler) toggle_selection() bool {
m := h.current_abspath()
if m != "" {
h.state.ToggleSelection(m)
return true
}
return false
}
func (h *Handler) change_to_current_dir_if_possible() error {
matches, _ := h.get_results()
if len(matches) > 0 {
m := h.current_abspath()
if st, err := os.Stat(m); err == nil {
if !st.IsDir() {
m = filepath.Dir(m)
}
h.state.SetCurrentDir(m)
h.result_manager.set_root_dir(h.state.CurrentDir())
return h.draw_screen()
}
}
h.lp.Beep()
return nil
}
func (h *Handler) finish_selection() error {
if h.state.mode.CanSelectNonExistent() {
h.initialize_save_file_name()
return h.draw_screen()
}
h.lp.Quit(0)
return nil
}
func (h *Handler) OnKeyEvent(ev *loop.KeyEvent) (err error) {
switch h.state.screen {
case NORMAL:
switch {
case h.handle_edit_keys(ev), h.handle_result_list_keys(ev):
h.draw_screen()
case ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("ctrl+c"):
h.lp.Quit(1)
case ev.MatchesPressOrRepeat("tab"):
return h.change_to_current_dir_if_possible()
case ev.MatchesPressOrRepeat("shift+tab"):
curr := h.state.CurrentDir()
switch curr {
case "/":
case ".":
if curr, err = os.Getwd(); err == nil && curr != "/" {
h.state.SetCurrentDir(filepath.Dir(curr))
h.result_manager.set_root_dir(h.state.CurrentDir())
return h.draw_screen()
}
default:
h.state.SetCurrentDir(filepath.Dir(curr))
h.result_manager.set_root_dir(h.state.CurrentDir())
return h.draw_screen()
}
h.lp.Beep()
case ev.MatchesPressOrRepeat("shift+enter"):
if !h.toggle_selection() {
h.lp.Beep()
} else {
if len(h.state.selections) > 0 && !h.state.mode.AllowsMultipleSelection() {
return h.finish_selection()
}
return h.draw_screen()
}
case ev.MatchesPressOrRepeat("enter"):
if h.state.mode.SelectFiles() {
m := h.current_abspath()
var s os.FileInfo
if s, err = os.Stat(m); err != nil {
h.lp.Beep()
return nil
}
if s.IsDir() {
return h.change_to_current_dir_if_possible()
}
}
if h.add_selection_if_possible() {
if len(h.state.selections) > 0 {
return h.finish_selection()
}
return h.draw_screen()
} else {
h.lp.Beep()
}
}
case SAVE_FILE:
err = h.save_file_name_handle_key(ev)
}
return
}
func (h *Handler) OnText(text string, from_key_event, in_bracketed_paste bool) (err error) {
switch h.state.screen {
case NORMAL:
h.state.SetSearchText(h.state.SearchText() + text)
h.result_manager.set_query(h.state.SearchText())
return h.draw_screen()
case SAVE_FILE:
if err = h.rl.OnText(text, from_key_event, in_bracketed_paste); err == nil {
err = h.draw_screen()
}
}
return
}
func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) {
h.state = State{}
switch opts.Mode {
case "file":
h.state.mode = SELECT_SINGLE_FILE
case "files":
h.state.mode = SELECT_MULTIPLE_FILES
case "save-file":
h.state.mode = SELECT_SAVE_FILE
case "dir":
h.state.mode = SELECT_SINGLE_DIR
case "dirs":
h.state.mode = SELECT_MULTIPLE_DIRS
case "save-dir":
h.state.mode = SELECT_SAVE_DIR
case "dir-for-files":
h.state.mode = SELECT_SAVE_DIR_FOR_FILES
default:
h.state.mode = SELECT_SINGLE_FILE
}
h.state.suggested_save_file_name = opts.SuggestedSaveFileName
if opts.SuggestedSaveFilePath != "" {
switch h.state.mode {
case SELECT_SAVE_FILE, SELECT_SAVE_DIR, SELECT_SAVE_DIR_FOR_FILES:
if s, err := os.Stat(opts.SuggestedSaveFilePath); err == nil {
if (s.IsDir() && h.state.mode != SELECT_SAVE_FILE) || (!s.IsDir() && h.state.mode == SELECT_SAVE_FILE) {
if h.state.AddSelection(opts.SuggestedSaveFileName) {
return h.finish_selection()
}
}
}
}
}
return
}
var default_cwd string
func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
conf, err := load_config(opts)
if err != nil {
return 1, err
}
lp, err := loop.New()
if err != nil {
return 1, err
}
handler := Handler{lp: lp, err_chan: make(chan error, 8), rl: readline.New(lp, readline.RlInit{
Prompt: "> ", ContinuationPrompt: ". ",
})}
if err = handler.set_state_from_config(conf, opts); err != nil {
return 1, err
}
handler.result_manager = NewResultManager(handler.err_chan, &handler.state, lp.WakeupMainThread)
switch len(args) {
case 0:
if default_cwd, err = os.Getwd(); err != nil {
return
}
case 1:
default_cwd = args[0]
default:
return 1, fmt.Errorf("Can only specify one directory to search in")
}
default_cwd = utils.Expanduser(default_cwd)
if default_cwd, err = filepath.Abs(default_cwd); err != nil {
return
}
lp.OnInitialize = handler.OnInitialize
lp.OnResize = func(old, new_size loop.ScreenSize) (err error) {
handler.init_sizes(new_size)
return handler.draw_screen()
}
lp.OnKeyEvent = handler.OnKeyEvent
lp.OnText = handler.OnText
lp.OnWakeup = func() (err error) {
select {
case err = <-handler.err_chan:
default:
err = handler.draw_screen()
}
return
}
err = lp.Run()
if err != nil {
return 1, err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return 1, nil
}
if rc = lp.ExitCode(); rc == 0 {
fmt.Print(strings.Join(handler.state.selections, "\n"))
}
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}