diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index e3a1e9c7e..beb46002b 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -777,6 +777,20 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) var default_cwd string var use_light_colors bool +func quote_if_needed(x string) string { + if s, err := shlex.Split(x); len(s) == 1 && err == nil && !strings.Contains(x, "$") { + return x + } + return utils.QuoteStringForSH(x) +} + +func for_shell_relative(x string) string { + if rel, is_under, err := utils.RelativeIfUnder(default_cwd, x, false); err == nil && is_under { + x = rel + } + return quote_if_needed(x) +} + func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { if opts.ClearCache { c, err := preview_cache() @@ -818,14 +832,21 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { if tui.RunningAsUI() { fmt.Println(tui.KittenOutputSerializer()(payload)) } else { - m := strings.Join(selections, "\n") - if opts.OutputFormat == "json" { + var m string + switch opts.OutputFormat { + case "shell": + m = strings.Join(utils.Map(quote_if_needed, selections), " ") + case "shell-relative": + m = strings.Join(utils.Map(for_shell_relative, selections), " ") + case "text": + m = strings.Join(selections, "\n") + case "json": b, _ := json.MarshalIndent(payload, "", " ") m = string(b) } - fmt.Print(m) + os.Stdout.Write(utils.UnsafeStringToBytes(m)) if opts.WriteOutputTo != "" { - os.WriteFile(opts.WriteOutputTo, []byte(m), 0600) + os.WriteFile(opts.WriteOutputTo, utils.UnsafeStringToBytes(m), 0600) } } } diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 8b20d90b0..9a434bfec 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -150,6 +150,9 @@ def relative_path_if_possible(path: str, base: str) -> str: @result_handler(has_ready_notification=True) def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, boss: BossType) -> None: + import shlex + + from kitty.utils import shlex_split paths: list[str] = data.get('paths', []) if not paths: boss.ring_bell_if_allowed() @@ -160,6 +163,8 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, cwd = w.cwd_of_child if cwd: path = relative_path_if_possible(path, cwd) + if w.at_prompt and len(tuple(shlex_split(path))) > 1: + path = shlex.quote(path) w.paste_text(path) @@ -219,9 +224,13 @@ Path to a file to which the output is written in addition to STDOUT. --output-format -choices=text,json +choices=text,json,shell,shell-relative default=text -The format in which to write the output. +The format in which to write the output. The :code:`text` format is absolute paths separated by newlines. +The :code:`shell` format is quoted absolute paths separated by spaces, quoting is done only if needed. The +:code:shell-relative` format is the same as :code:`shell` except it returns paths relative to the starting +directory. Note that when invoked from a mapping, this option is ignored, +and either text or shell format is used automatically based on whether the cursor is at a shell prompt or not. --write-pid-to diff --git a/tools/utils/paths.go b/tools/utils/paths.go index 3dbced37d..013f63bd0 100644 --- a/tools/utils/paths.go +++ b/tools/utils/paths.go @@ -330,3 +330,60 @@ func Commonpath(paths ...string) (longest_prefix string) { } return } + +// RelativeIfUnder returns the path to 'target' relative to 'base' if and only if +// target is inside base. It returns the relative path, a boolean indicating +// whether target is inside base, and an error. +// +// If resolveSymlinks is true, both base and target are run through filepath.EvalSymlinks +// before containment checks. If base == target the function returns "." and true. +// +// Notes: +// - This uses filepath.Rel and then checks for leading ".." components to determine +// whether the returned relative path escapes the base directory. +// - On Windows behaviour is consistent with filepath semantics. +func RelativeIfUnder(base, target string, resolveSymlinks bool) (rel string, inside bool, err error) { + // Optionally resolve symlinks first + if resolveSymlinks { + if base, err = filepath.EvalSymlinks(base); err != nil { + return "", false, fmt.Errorf("resolving base symlinks: %w", err) + } + if target, err = filepath.EvalSymlinks(target); err != nil { + return "", false, fmt.Errorf("resolving target symlinks: %w", err) + } + } + + // Make absolute and clean + if base, err = filepath.Abs(base); err != nil { + return "", false, fmt.Errorf("abs base: %w", err) + } + if target, err = filepath.Abs(target); err != nil { + return "", false, fmt.Errorf("abs target: %w", err) + } + + // On Windows the volume (drive letter) must match. If they don't, the path is not inside. + if runtime.GOOS == "windows" { + if !strings.EqualFold(filepath.VolumeName(base), filepath.VolumeName(target)) { + return "", false, nil + } + } + + // Get the relative path from base to target + rel, err = filepath.Rel(base, target) + if err != nil { + return "", false, fmt.Errorf("computing relative path: %w", err) + } + + // If rel begins with ".." (or is ".."), then target is outside base. + // Use os.PathSeparator to be portable. + up := ".." + string(os.PathSeparator) + if rel == ".." || strings.HasPrefix(rel, up) { + return "", false, nil + } + + // If the returned rel is empty (shouldn't normally happen), normalize to "." + if rel == "" { + rel = "." + } + return rel, true, nil +}