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 matches[idx].abspath } } 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) }