More work on choose files integration

This commit is contained in:
Kovid Goyal
2025-07-02 14:49:24 +05:30
parent 3e56def777
commit 7375ee5e52
5 changed files with 291 additions and 18 deletions

View File

@@ -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
}

View File

@@ -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 = '''\

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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
}