From 7375ee5e52c12602357f95495ebdf60c7ed01e7f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 2 Jul 2025 14:49:24 +0530 Subject: [PATCH] More work on choose files integration --- kittens/choose_files/main.go | 67 +++++++++-- kittens/choose_files/main.py | 14 ++- kittens/desktop_ui/main.go | 3 + kittens/desktop_ui/main.py | 8 ++ kittens/desktop_ui/portal.go | 217 ++++++++++++++++++++++++++++++++++- 5 files changed, 291 insertions(+), 18 deletions(-) diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index daf22f882..91b023647 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -1,6 +1,7 @@ package choose_files import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -33,15 +34,15 @@ const ( SELECT_SINGLE_FILE Mode = iota SELECT_MULTIPLE_FILES SELECT_SAVE_FILE + SELECT_SAVE_FILES 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: + case SELECT_SAVE_FILE, SELECT_SAVE_DIR, SELECT_SAVE_FILES: return true } return false @@ -49,7 +50,7 @@ func (m Mode) CanSelectNonExistent() bool { func (m Mode) AllowsMultipleSelection() bool { switch m { - case SELECT_MULTIPLE_FILES, SELECT_MULTIPLE_DIRS: + case SELECT_MULTIPLE_FILES, SELECT_MULTIPLE_DIRS, SELECT_SAVE_FILES: return true } return false @@ -57,7 +58,7 @@ func (m Mode) AllowsMultipleSelection() bool { func (m Mode) OnlyDirs() bool { switch m { - case SELECT_SINGLE_DIR, SELECT_MULTIPLE_DIRS, SELECT_SAVE_DIR, SELECT_SAVE_DIR_FOR_FILES: + case SELECT_SINGLE_DIR, SELECT_MULTIPLE_DIRS, SELECT_SAVE_DIR: return true } return false @@ -65,7 +66,7 @@ func (m Mode) OnlyDirs() bool { func (m Mode) SelectFiles() bool { switch m { - case SELECT_SINGLE_FILE, SELECT_MULTIPLE_FILES, SELECT_SAVE_FILE: + case SELECT_SINGLE_FILE, SELECT_MULTIPLE_FILES, SELECT_SAVE_FILE, SELECT_SAVE_FILES: return true } return false @@ -85,8 +86,8 @@ func (m Mode) WindowTitle() string { 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" + case SELECT_SAVE_FILES: + return "Choose files to save" } return "" } @@ -384,15 +385,15 @@ func (h *Handler) set_state_from_config(_ *Config, opts *Options) (err error) { 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 + case "save-files": + h.state.mode = SELECT_SAVE_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: + case SELECT_SAVE_FILE, SELECT_SAVE_DIR: 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) { @@ -408,6 +409,42 @@ func (h *Handler) set_state_from_config(_ *Config, opts *Options) (err error) { var default_cwd string func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { + write_output := func(selections []string, interrupted bool) { + payload := make(map[string]any) + if err != nil { + if opts.WriteOutputTo != "" { + m := fmt.Sprint(err) + if opts.OutputFormat == "json" { + payload["error"] = m + b, _ := json.MarshalIndent(payload, "", " ") + m = string(b) + } + os.WriteFile(opts.WriteOutputTo, []byte(m), 0600) + } + return + } + if interrupted { + if opts.WriteOutputTo != "" { + if opts.OutputFormat == "json" { + payload["interrupted"] = true + b, _ := json.MarshalIndent(payload, "", " ") + os.WriteFile(opts.WriteOutputTo, b, 0600) + } + } + return + } + m := strings.Join(selections, "\n") + fmt.Print(m) + if opts.WriteOutputTo != "" { + if opts.OutputFormat == "json" { + payload["paths"] = selections + b, _ := json.MarshalIndent(payload, "", " ") + m = string(b) + } + os.WriteFile(opts.WriteOutputTo, []byte(m), 0600) + } + } + conf, err := load_config(opts) if err != nil { return 1, err @@ -454,16 +491,22 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { } err = lp.Run() if err != nil { + write_output(nil, false) return 1, err } ds := lp.DeathSignalName() if ds != "" { fmt.Println("Killed by signal: ", ds) lp.KillIfSignalled() + write_output(nil, true) return 1, nil } - if rc = lp.ExitCode(); rc == 0 { - fmt.Print(strings.Join(handler.state.selections, "\n")) + rc = lp.ExitCode() + switch rc { + case 0: + write_output(handler.state.selections, false) + default: + write_output(nil, true) } return } diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 1f58b2fb4..fbe9e2248 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -39,7 +39,7 @@ completion=type:file ext:conf group:"Config files" kwds:none,NONE --mode type=choices -choices=file,files,save-file,dir,save-dir,dirs,dir-for-files +choices=file,files,save-file,dir,save-dir,dirs,save-files default=file The type of object(s) to select @@ -50,7 +50,17 @@ A suggested name when picking a save file. --suggested-save-file-path Path to an existing file to use as the save file. -'''.format(config_help=CONFIG_HELP.format(conf_name='diff', appname=appname)).format + + +--write-output-to +Path to a file to which the output is written in addition to STDOUT. + + +--output-format +choices=text,json +default=text +The format in which to write the output. +'''.format(config_help=CONFIG_HELP.format(conf_name='choose-files', appname=appname)).format help_text = '''\ diff --git a/kittens/desktop_ui/main.go b/kittens/desktop_ui/main.go index 38a6a5a3f..168e55ccb 100644 --- a/kittens/desktop_ui/main.go +++ b/kittens/desktop_ui/main.go @@ -33,6 +33,9 @@ func run_server(opts *ServerOptions) (err error) { portal, err := NewPortal(config) if err == nil { err = portal.Start() + if err == nil { + defer portal.Cleanup() + } } } if err != nil { diff --git a/kittens/desktop_ui/main.py b/kittens/desktop_ui/main.py index 19c6bafd0..8ddc63505 100644 --- a/kittens/desktop_ui/main.py +++ b/kittens/desktop_ui/main.py @@ -23,6 +23,14 @@ by using :code:`kitten desktop-ui color-scheme`. ''') opt('accent_color', 'cyan', long_text='The RGB accent color for your system, can be specified as a color name or in hex a decimal format.') opt('contrast', 'normal', choices=('normal', 'high'), long_text='The preferred contrast level.') +opt('+file_chooser_kitty_conf', '', + long_text='Path to config file to use for kitty when drawing the file chooser window. Can be specified multiple times. By default, the' + ' normal kitty.conf is used. Relative paths are resolved with respect to the kitty config directory.' +) +opt('+file_chooser_kitty_override', '', long_text='Override individual kitty configuration options, for the file chooser window.' + ' Can be specified multiple times. Syntax: :italic:`name=value`. For example: :code:`font_size=20`.' +) + egr() diff --git a/kittens/desktop_ui/portal.go b/kittens/desktop_ui/portal.go index 8140f0fc7..3983f50cc 100644 --- a/kittens/desktop_ui/portal.go +++ b/kittens/desktop_ui/portal.go @@ -4,10 +4,16 @@ import ( "encoding/json" "fmt" "maps" + "net/url" "os" + "os/exec" "path/filepath" + "runtime" + "slices" + "strconv" "strings" "sync" + "time" "github.com/kovidgoyal/dbus" "github.com/kovidgoyal/dbus/introspect" @@ -42,13 +48,20 @@ const ( DARK LIGHT ) +const ( + RESPONSE_SUCCESS uint32 = iota + RESPONSE_CANCELED + RESPONSE_ENDED +) type SettingsMap map[string]map[string]dbus.Variant type Portal struct { - bus *dbus.Conn - settings SettingsMap - lock sync.Mutex + bus *dbus.Conn + settings SettingsMap + lock sync.Mutex + opts *Config + file_chooser_first_instance *exec.Cmd } func to_color(spec string) (v dbus.Variant, err error) { @@ -59,7 +72,7 @@ func to_color(spec string) (v dbus.Variant, err error) { } func NewPortal(opts *Config) (p *Portal, err error) { - ans := Portal{} + ans := Portal{opts: opts} ans.settings = SettingsMap{ SETTINGS_CANARY_NAMESPACE: map[string]dbus.Variant{ SETTINGS_CANARY_KEY: dbus.MakeVariant("running"), @@ -186,6 +199,17 @@ func (self *Portal) Start() (err error) { if err = ExportInterface(self.bus, self, SETTINGS_INTERFACE, DESKTOP_OBJECT_PATH, methods, props, signals); err != nil { return } + methods = MethodSpec{ + "OpenFile": {{"handle", "o", false}, {"app_id", "s", false}, {"parent_window", "s", false}, {"title", "s", false}, {"options", "a{sv}", false}, + {"response", "u", true}, {"results", "a{sv}", false}, + }, + "SaveFile": {{"handle", "o", false}, {"app_id", "s", false}, {"parent_window", "s", false}, {"title", "s", false}, {"options", "a{sv}", false}, + {"response", "u", true}, {"results", "a{sv}", false}, + }, + } + if err = ExportInterface(self.bus, self, FILE_CHOOSER_INTERFACE, DESKTOP_OBJECT_PATH, methods, nil, nil); err != nil { + return + } methods = MethodSpec{ "ChangeSetting": {{"namespace", "s", false}, {"key", "s", false}, {"value", "v", false}}, "RemoveSetting": {{"namespace", "s", false}, {"key", "s", false}}, @@ -600,3 +624,188 @@ func (self *Portal) ReadAll(namespaces []string) (ReadAllType, *dbus.Error) { } return values, nil } + +type vmap map[string]dbus.Variant +type ChooseFilesData struct { + Title string + Mode string + Cwd string + SuggestedSaveFileName, SuggestedSaveFilePath string +} + +func var_to_bool_or_false(v dbus.Variant) bool { + if ans, ok := v.Value().(bool); ok { + return ans + } + return false +} + +func (self *Portal) Cleanup() { + self.lock.Lock() + defer self.lock.Unlock() + if self.file_chooser_first_instance != nil { + self.file_chooser_first_instance.Process.Signal(unix.SIGTERM) + ch := make(chan int) + go func() { + self.file_chooser_first_instance.Wait() + ch <- 0 + }() + select { + case <-ch: + case <-time.After(time.Second): + self.file_chooser_first_instance.Process.Kill() + self.file_chooser_first_instance.Wait() + } + self.file_chooser_first_instance = nil + } +} + +type ChooserResponse struct { + Paths []string `json:"paths"` + Error string `json:"error"` + Interrupted bool `json:"interrupted"` +} + +func (self *Portal) run_file_chooser(cfd ChooseFilesData) (response uint32, uris []string) { + response = RESPONSE_ENDED + tdir, err := os.MkdirTemp("", "kitty-cfd") + if err != nil { + fmt.Fprintf(os.Stderr, "cannot run file chooser as failed to create a temporary directory with error: %s\n", err) + return + } + defer os.RemoveAll(tdir) + output_path := filepath.Join(tdir, "output.json") + cmd := func() *exec.Cmd { + self.lock.Lock() + defer self.lock.Unlock() + args := []string{ + "+kitten", "panel", "--layer=overlay", "--edge=center", "--focus-policy=exclusive", + "-o", "background_opacity=0.85", "--wait-for-single-instance-window-close", + "--single-instance", "--instance-group", "cfp-" + strconv.Itoa(os.Getpid()), + } + for _, x := range self.opts.File_chooser_kitty_conf { + args = append(args, `-c`, x) + } + for _, x := range self.opts.File_chooser_kitty_override { + args = append(args, `-o`, x) + } + if self.file_chooser_first_instance == nil { + fifo_path := filepath.Join(tdir, "fifo") + if err := unix.Mkfifo(fifo_path, 0600); err != nil { + fmt.Fprintf(os.Stderr, "cannot run file chooser as failed to create a fifo directory with error: %s\n", err) + return nil + } + fa := slices.Clone(args) + fa = append(fa, "--start-as-hidden", "sh", "-c", "echo a > '"+fifo_path+"'; read") + cmd := exec.Command(utils.KittyExe(), fa...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Start() + ch := make(chan int) + go func() { + f, err := os.OpenFile(fifo_path, os.O_RDONLY, os.ModeNamedPipe) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot run file chooser as failed to open fifo for read with error: %s\n", err) + } + b := []byte{'a', 'b', 'c', 'd'} + f.Read(b) + ch <- 0 + }() + select { + case <-ch: + self.file_chooser_first_instance = cmd + case <-time.After(5 * time.Second): + fmt.Fprintf(os.Stderr, "cannot run file chooser as panel script timed out writing to fifo") + return nil + } + } + args = append(args, "kitten", `choose-files`, `--mode`, cfd.Mode, `--write-output-to`, output_path, `--output-format=json`) + if cfd.SuggestedSaveFileName != "" { + args = append(args, `--suggested-save-file-name`, cfd.SuggestedSaveFileName) + } + if cfd.SuggestedSaveFilePath != "" { + args = append(args, `--suggested-save-file-path`, cfd.SuggestedSaveFilePath) + } + cmd := exec.Command(utils.KittyExe(), args...) + cmd.Dir = cfd.Cwd + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd + }() + if cmd == nil { + return + } + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "running file chooser failed with error: %s\n", err) + return + } + raw, err := os.ReadFile(output_path) + if err != nil { + fmt.Fprintf(os.Stderr, "running file chooser failed, could not read from output file with error: %s\n", err) + return + } + var result ChooserResponse + if err = json.Unmarshal(raw, &result); err != nil { + fmt.Fprintf(os.Stderr, "running file chooser failed, invalid JSON response with error: %s\n", err) + return + } + if result.Error != "" { + fmt.Fprintf(os.Stderr, "running file chooser failed, with error: %s\n", result.Error) + return + } + if result.Interrupted { + response = RESPONSE_CANCELED + fmt.Fprintf(os.Stderr, "running file chooser failed, interrupted by user.\n") + return + } + response = RESPONSE_SUCCESS + prefix := "file://" + utils.IfElse(runtime.GOOS == "windows", "/", "") + uris = utils.Map(func(path string) string { + path = filepath.ToSlash(path) + u := url.URL{Path: path} + return prefix + u.EscapedPath() + }, result.Paths) + return +} + +func (options vmap) get_bytearray(name string) string { + if v, found := options[name]; found { + if b, ok := v.Value().([]byte); ok { + return string(b) + } + } + return "" +} + +func (self *Portal) OpenFile(handle dbus.ObjectPath, app_id string, parent_window string, title string, options vmap) (uint32, vmap, *dbus.Error) { + cfd := ChooseFilesData{Title: title, Cwd: options.get_bytearray("current_folder")} + dir_only := false + if v, found := options["directory"]; found && var_to_bool_or_false(v) { + dir_only = true + } + multiple := false + if v, found := options["multiple"]; found && var_to_bool_or_false(v) { + multiple = true + } + if dir_only { + cfd.Mode = utils.IfElse(multiple, "dirs", "dir") + } else { + cfd.Mode = utils.IfElse(multiple, "files", "file") + } + + return RESPONSE_CANCELED, nil, nil +} + +func (self *Portal) SaveFile(handle dbus.ObjectPath, app_id string, parent_window string, title string, options vmap) (uint32, vmap, *dbus.Error) { + cfd := ChooseFilesData{ + Title: title, Cwd: options.get_bytearray("current_folder"), + SuggestedSaveFileName: options.get_bytearray("current_name"), + SuggestedSaveFilePath: options.get_bytearray("current_file")} + multiple := false + if v, found := options["multiple"]; found && var_to_bool_or_false(v) { + multiple = true + } + cfd.Mode = utils.IfElse(multiple, "save-files", "save-file") + + return RESPONSE_CANCELED, nil, nil +}