// License: GPLv3 Copyright: 2022, Kovid Goyal, package dnd import ( "bytes" "errors" "fmt" "io" "maps" "net/url" "os" "path/filepath" "runtime" "slices" "strconv" "strings" "sync/atomic" "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tui/loop" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/streaming_base64" "github.com/kovidgoyal/kitty/tools/wcswidth" ) var _ = fmt.Append var debugprintln = tty.DebugPrintln var _ = debugprintln type uri_list_item struct { path, uri, human_name string file *os.File } type drag_source struct { human_name, path string file *os.File mime_type string uri_list []uri_list_item data []byte } type bufferWriteCloser struct { *bytes.Buffer } // Close implements the io.Closer interface (as a no-op) func (bwc *bufferWriteCloser) Close() error { return nil } type drop_dest struct { human_name, path string dest io.WriteCloser mime_type string completed bool close_on_finish bool b64_decoder streaming_base64.StreamingBase64Decoder } func open_file_for_writing(path string) (*os.File, error) { f, err := os.Create(path) if errors.Is(err, os.ErrNotExist) { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } return os.Create(path) } return f, err } func (d *drop_dest) write(chunk []byte) (err error) { if d.dest == nil { d.dest, err = open_file_for_writing(d.path) d.close_on_finish = true if err != nil { return } } _, err = d.dest.Write(chunk) return } func (d *drop_dest) finish() error { defer func() { d.completed = true if d.dest != nil && d.close_on_finish { d.dest.Close() d.dest = nil } }() if chunk, err := d.b64_decoder.Finish(); err != nil { return err } else if len(chunk) > 0 { return d.write(chunk) } return nil } func (d *drop_dest) add_data(x []byte, output_buf []byte, has_more bool) error { d.completed = false for chunk, err := range d.b64_decoder.Decode(x, output_buf) { if err == nil { err = d.write(chunk) } if err != nil { return err } } if !has_more { if chunk, err := d.b64_decoder.Finish(); err != nil { return err } else if len(chunk) > 0 { return d.write(chunk) } } return nil } type button_region struct { left, width, top, height int } func (r button_region) has(x, y int) bool { return r.left <= x && x < r.left+r.width && r.top <= y && y < r.top+r.height } type DC = loop.DndCommand func truncate_at_space(text string, width int) (string, string) { truncated, p := wcswidth.TruncateToVisualLengthWithWidth(text, width) if len(truncated) == len(text) { return text, "" } i := strings.LastIndexByte(truncated, ' ') if i > 0 && p-i < 12 { p = i + 1 } return text[:p], text[p:] } type dir_handle struct { handle *os.File refcnt int32 } func new_dir_handle(x *os.File) *dir_handle { return &dir_handle{x, 1} } func (d *dir_handle) newref() *dir_handle { atomic.AddInt32(&d.refcnt, 1) return d } func (d *dir_handle) unref() *dir_handle { if atomic.AddInt32(&d.refcnt, -1) <= 0 { d.handle.Close() d.handle = nil } return nil } type remote_dir_entry struct { base_dir *dir_handle name string item_type int children []*remote_dir_entry num_children_finished int dest io.WriteCloser b64_decoder streaming_base64.StreamingBase64Decoder } const case_conflict_template = "case-conflict-%d-%s" func uniqify_child_names(names []string, is_case_sensitive_filesystem bool) []string { if is_case_sensitive_filesystem { seen := utils.NewSet[string](len(names)) for i, x := range names { name := x key := strings.ToLower(name) for q := 0; seen.Has(key); q++ { name = fmt.Sprintf(case_conflict_template, q+1, x) key = strings.ToLower(name) } seen.Add(key) names[i] = name } } return names } func (d *remote_dir_entry) add_remote_data(data []byte, output_buf []byte, has_more bool, parent *remote_dir_entry, is_case_sensitive_filesystem bool) error { if len(data) > 0 { for chunk, derr := range d.b64_decoder.Decode(data, output_buf) { if derr != nil { return derr } if _, derr = d.dest.Write(chunk); derr != nil { return derr } } } else if !has_more { if chunk, derr := d.b64_decoder.Finish(); derr != nil { return derr } else { if _, derr = d.dest.Write(chunk); derr != nil { return derr } } defer func() { d.dest.Close() d.dest = nil d.base_dir = d.base_dir.unref() parent.num_children_finished++ }() if dest, ok := d.dest.(*bufferWriteCloser); ok { if d.item_type == 1 { if derr := utils.SymlinkAt(d.base_dir.handle, d.name, dest.String()); derr != nil { return derr } } else { // directory if derr := utils.MkdirAt(d.base_dir.handle, d.name, 0o755); derr != nil { return derr } if f, derr := utils.OpenAt(d.base_dir.handle, d.name); derr != nil { return derr } else { handle := new_dir_handle(f) defer handle.unref() s := utils.NewSeparatorScanner("", "\x00") for _, name := range uniqify_child_names(s.Split(dest.String()), is_case_sensitive_filesystem) { d.children = append(d.children, &remote_dir_entry{name: name, base_dir: handle.newref()}) } } } } } return nil } type drop_status struct { offered_mimes []string accepted_mimes []string uri_list []string cell_x, cell_y int action int in_window bool reading_data bool is_remote_client bool root_remote_dir *remote_dir_entry open_remote_dir *remote_dir_entry pending_remote_dirs []*remote_dir_entry } func paragraph_as_lines(text string, width int) (ans []string) { for text != "" { var line string if line, text = truncate_at_space(text, width); line != "" { ans = append(ans, line) } } return } func parse_uri_list(src string) (ans []string, err error) { for _, line := range utils.NewSeparatorScanner("", "\r\n").Split(src) { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") { continue } if !strings.HasPrefix(line, "file://") { ans = append(ans, "") continue } p, err := url.Parse(line) if err != nil { return nil, err } ans = append(ans, filepath.Clean(p.Path)) } return } type drag_status struct { active bool } type dnd struct { opts *Options drop_dests map[string]*drop_dest drag_sources map[string]drag_source allow_drops, allow_drags bool lp *loop.Loop drop_status drop_status base_tempdir *os.File is_case_sensitive_filesystem bool data_has_been_dropped bool drag_started bool in_test_mode bool copy_button_region, move_button_region button_region } func (dnd *dnd) send_test_response(payload string) { dnd.lp.DebugPrintln(payload) } func (dnd *dnd) run_loop() (err error) { base_dir, err := os.Getwd() if err != nil { return err } base_tdir, err := os.MkdirTemp(base_dir, ".dnd-kitten-drop-*") if err != nil { return err } var base_tdir_f *os.File defer func() { if base_tdir_f != nil { utils.RemoveChildren(base_tdir_f) base_tdir_f.Close() } if terr := os.RemoveAll(base_tdir); terr != nil && err == nil { err = terr } }() base_tdir_f, err = os.Open(base_tdir) if err != nil { return } if _, serr := os.Stat(filepath.Join(base_dir, strings.ToUpper(filepath.Base(base_tdir)))); serr == nil { dnd.is_case_sensitive_filesystem = false } tdir_counter := 0 new_tdir := func() (dir_file *os.File, err error) { tdir_counter++ name := strconv.Itoa(tdir_counter) if err = utils.MkdirAt(base_tdir_f, name, 0o700); err != nil { return nil, err } dir_file, err = utils.OpenAt(base_tdir_f, name) return } dnd.allow_drops, dnd.allow_drags = len(dnd.drop_dests) > 0, len(dnd.drag_sources) > 0 if dnd.lp, err = loop.New(); err != nil { return err } drop_status := drop_status{cell_x: -1, cell_y: -1} reset_drop_status := drop_status drop_status.cell_x, drop_status.cell_y = -1, -1 var offered_mimes_buf strings.Builder // Drop handling {{{ var close_remote_tree func(*remote_dir_entry) close_remote_tree = func(root *remote_dir_entry) { if root.base_dir != nil { root.base_dir = root.base_dir.unref() } for _, child := range root.children { close_remote_tree(child) } } end_drop := func() { dnd.lp.QueueDnDData(DC{Type: 'r'}) // end drop if drop_status.root_remote_dir != nil { close_remote_tree(drop_status.root_remote_dir) drop_status.root_remote_dir = nil } drop_status = reset_drop_status dnd.render_screen() } all_mime_data_dropped := func() (err error) { if s, found := dnd.drop_dests["text/uri-list"]; found { b := s.dest.(*bufferWriteCloser) if drop_status.uri_list, err = parse_uri_list(b.String()); err != nil { return err } } if len(drop_status.uri_list) == 0 { drop_status = reset_drop_status dnd.data_has_been_dropped = true dnd.render_screen() return } f, err := new_tdir() if err != nil { return err } rd := new_dir_handle(f) defer rd.unref() drop_status.root_remote_dir = &remote_dir_entry{} if drop_status.is_remote_client { seen := utils.NewSet[string](len(drop_status.uri_list)) idx := slices.Index(drop_status.offered_mimes, "text/uri-list") for i, x := range drop_status.uri_list { var c *remote_dir_entry if x == "" { c = &remote_dir_entry{} } else { name := filepath.Base(x) if !dnd.is_case_sensitive_filesystem { key := strings.ToLower(name) for q := 0; seen.Has(key); q++ { name = fmt.Sprintf(case_conflict_template, q+1, filepath.Base(x)) key = strings.ToLower(name) } seen.Add(key) } c = &remote_dir_entry{base_dir: rd.newref(), name: name} dnd.lp.QueueDnDData(DC{Type: 'r', X: idx + 1, Y: i + 1}) } drop_status.root_remote_dir.children = append(drop_status.root_remote_dir.children, c) } drop_status.open_remote_dir = drop_status.root_remote_dir } else { // TODO: copy URLs } return } request_mime_data := func() { accepted := utils.NewSetWithItems(drop_status.accepted_mimes...) for idx, m := range drop_status.offered_mimes { if accepted.Has(m) { dnd.lp.QueueDnDData(DC{Type: 'r', X: idx + 1}) } } } on_drop_move := func(cell_x, cell_y int, has_more bool, offered_mimes string, is_drop bool) (needs_rerender bool) { prev_status := drop_status drop_status.cell_x, drop_status.cell_y = cell_x, cell_y if offered_mimes != "" { offered_mimes_buf.WriteString(offered_mimes) if has_more { return } offered_mimes := offered_mimes_buf.String() drop_status.offered_mimes = strings.Fields(offered_mimes) drop_status.accepted_mimes = make([]string, 0, len(drop_status.offered_mimes)) seen := utils.NewSet[string](len(drop_status.offered_mimes)) for _, x := range drop_status.offered_mimes { if _, found := dnd.drop_dests[x]; found && !seen.Has(x) { drop_status.accepted_mimes = append(drop_status.accepted_mimes, x) seen.Add(x) } } } offered_mimes_buf.Reset() if dnd.copy_button_region.has(cell_x, cell_y) { drop_status.action = copy_on_drop } else if dnd.move_button_region.has(cell_x, cell_y) { drop_status.action = move_on_drop } else { switch dnd.opts.DropAnywhere { case "disallowed": drop_status.action = 0 drop_status.accepted_mimes = nil case "copy": drop_status.action = copy_on_drop case "move": drop_status.action = move_on_drop } } drop_status.in_window = cell_x > -1 && cell_y > -1 if !drop_status.in_window || dnd.drag_started { // disallow self drag and drop drop_status = reset_drop_status } mimes_changed := !slices.Equal(prev_status.accepted_mimes, drop_status.accepted_mimes) needs_rerender = prev_status.action != drop_status.action || mimes_changed if needs_rerender && !is_drop { c := DC{Type: 'm', Operation: drop_status.action} if drop_status.action != 0 && len(drop_status.accepted_mimes) > 0 { c.Payload = utils.UnsafeStringToBytes(strings.Join(drop_status.accepted_mimes, " ")) } dnd.lp.QueueDnDData(c) } needs_rerender = needs_rerender || drop_status.in_window != prev_status.in_window if is_drop { needs_rerender = true if drop_status.action == 0 || len(drop_status.accepted_mimes) == 0 || dnd.drag_started { end_drop() return } drop_status.reading_data = true request_mime_data() } return } var current_remote_entry *remote_dir_entry var drop_buf []byte on_remote_drop_data := func(cmd DC) error { if drop_status.open_remote_dir == nil { return fmt.Errorf("got a remote data response form the terminal without an open remote dir") } if cmd.X == 0 && cmd.Y == 0 && cmd.Yp == 0 { if current_remote_entry == nil { return fmt.Errorf("got a remote data response form the terminal without a current remote entry") } } else { num := utils.IfElse(cmd.Yp != 0 && cmd.Yp != 1, cmd.X, cmd.Y) - 1 if num < 0 || num >= len(drop_status.open_remote_dir.children) { return fmt.Errorf("got a remote data response from the terminal for an entry that does not exist") } current_remote_entry = drop_status.open_remote_dir.children[num] } if current_remote_entry.dest == nil { current_remote_entry.item_type = cmd.Xp switch cmd.Xp { case 0: f, err := utils.CreateAt(drop_status.open_remote_dir.base_dir.handle, current_remote_entry.name) if err != nil { return err } current_remote_entry.dest = f default: current_remote_entry.dest = &bufferWriteCloser{&bytes.Buffer{}} } } if sz := max(4096, len(cmd.Payload)+4); len(drop_buf) < sz { drop_buf = make([]byte, sz) } if err = current_remote_entry.add_remote_data(cmd.Payload, drop_buf, cmd.Has_more, drop_status.open_remote_dir, dnd.is_case_sensitive_filesystem); err != nil { return err } return nil } on_drop_data := func(cmd DC) error { if drop_status.root_remote_dir != nil { return on_remote_drop_data(cmd) } idx := cmd.X - 1 if idx < 0 || idx > len(drop_status.offered_mimes) { return fmt.Errorf("terminal sent drop data for a index outside the list of accepted MIMEs") } mime := drop_status.offered_mimes[idx] dest := dnd.drop_dests[mime] if cmd.Xp == 1 && mime == "text/uri-list" { drop_status.is_remote_client = true } if !cmd.Has_more && len(cmd.Payload) == 0 { if err := dest.finish(); err != nil { return err } dest.completed = true pending := false for _, d := range dnd.drop_dests { if !d.completed { pending = true break } } if !pending { return all_mime_data_dropped() } return nil } if sz := max(4096, len(cmd.Payload)+4); len(drop_buf) < sz { drop_buf = make([]byte, sz) } return dest.add_data(cmd.Payload, drop_buf, cmd.Has_more) } // }}} dnd.lp.OnInitialize = func() (string, error) { dnd.lp.AllowLineWrapping(false) dnd.lp.SetCursorVisible(false) if dnd.allow_drops { dnd.lp.StartAcceptingDrops(dnd.opts.MachineId, slices.Collect(maps.Keys(dnd.drop_dests))...) } if dnd.allow_drags { dnd.lp.StartOfferingDrags(dnd.opts.MachineId) } dnd.lp.SetWindowTitle("Drag and drop") return "", dnd.render_screen() } dnd.lp.OnFinalize = func() string { dnd.lp.AllowLineWrapping(true) dnd.lp.SetCursorVisible(true) if dnd.allow_drops { dnd.lp.StopAcceptingDrops() } if dnd.allow_drags { dnd.lp.StopOfferingDrags() } return "" } dnd.lp.OnDnDData = func(cmd loop.DndCommand) error { // TODO: Use lp.QueueDnDData to implement drag and drop protocol // If allow_drags, start a drag when the terminal sends the t=o // event. Presend data for any drag_source objects that have non nil // data fields and whose data size is <= 1MB. Set drag_started to true. // reset drag_started at the end of the drag. Use opts.DragAction to // set what actions are allowed. // If a drop enters the window and has one or more MIME types present // in drop_dests, accept the drop, unless drag_started is true. // Redraw the screen whenever drag or drop status changes. // When a drop happens, write all data for the MIME types present in // both drop_dests and the actual dropped data. For the text/uri-list // type if the terminal indicates it is coming from a remote machine // request the data for the file:// entries from the uri-list using the // dnd protocol and write it, otherwise, copy the file URLs using // normal file system operations. If opts.ConfirmDropOverwrite is true // then when some data would overwrite existing file, put it into a // temp file instead and after all data is transferred as the user for // confirmation and overwrite or not accordingly. While a drop is in // progress the render_screen() function should hide the drop // destination buttons and instead show the text "Drop in progress, // reading data..." // Be very careful when writing dropped data from uri-list nothing // should be written outside the destination directory (the current // working directory by default). In particular, symlinks must be // handled with care. // When acting as a drag source, dont forget to implement support for // remote dragging, which means providing data for the text/uri-list // mime type file:// entries when the terminal requests it using the // dnd protocol. If the action chosen is move, delete the files // corresponding to the drag sources, including the files in the // uri-list and exit. switch cmd.Type { case 'T': switch string(cmd.Payload) { case "PING": dnd.send_test_response("PONG") case "SETUP": dnd.in_test_mode = true dnd.lp.NoRoundtripToTerminalOnExit() case "GEOMETRY": dnd.send_test_response(fmt.Sprintf("GEOMETRY:%d:%d:%d:%d:%d:%d:%d:%d", dnd.copy_button_region.left, dnd.copy_button_region.top, dnd.copy_button_region.width, dnd.copy_button_region.height, dnd.move_button_region.left, dnd.move_button_region.top, dnd.move_button_region.width, dnd.move_button_region.height)) case "DROP_MIMES": if drop_status.offered_mimes != nil { dnd.send_test_response(strings.Join(drop_status.offered_mimes, " ")) } else { dnd.send_test_response("") } case "DROP_IS_REMOTE": dnd.send_test_response(utils.IfElse(drop_status.is_remote_client, "True", "False")) case "DROP_URI_LIST": dnd.send_test_response(strings.Join(drop_status.uri_list, "|")) default: dnd.send_test_response("UNKNOWN TEST COMMAND: " + string(cmd.Payload)) } // Drops case 'm': payload := "" if cmd.Payload != nil { payload = utils.UnsafeBytesToString(cmd.Payload) } if on_drop_move(cmd.X, cmd.Y, cmd.Has_more, payload, false) { dnd.render_screen() } case 'M': if on_drop_move(cmd.X, cmd.Y, cmd.Has_more, utils.UnsafeBytesToString(cmd.Payload), true) { dnd.render_screen() } case 'R': return fmt.Errorf("error from the terminal while reading dropped data: %s", string(cmd.Payload)) case 'r': err := on_drop_data(cmd) dnd.render_screen() return err } return nil } dnd.lp.OnKeyEvent = func(e *loop.KeyEvent) (err error) { e.Handled = true if e.MatchesPressOrRepeat("ctrl+c") || e.MatchesPressOrRepeat("esc") { dnd.lp.Quit(0) return } return nil } dnd.lp.OnResize = func(old_size loop.ScreenSize, new_size loop.ScreenSize) error { return dnd.render_screen() } err = dnd.lp.Run() if err != nil { return } ds := dnd.lp.DeathSignalName() if ds != "" { fmt.Println("Killed by signal: ", ds) dnd.lp.KillIfSignalled() return } return } func dnd_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { drop_dests := make(map[string]*drop_dest) if os.Stdout != nil && !tty.IsTerminal(os.Stdout.Fd()) { drop_dests["text/plain"] = &drop_dest{human_name: "STDOUT", dest: os.Stdout, mime_type: "text/plain"} } drop_dests["text/uri-list"] = &drop_dest{ human_name: "Files", mime_type: "text/uri-list", dest: &bufferWriteCloser{&bytes.Buffer{}}} for _, spec := range opts.Drop { mime, dest, _ := strings.Cut(spec, ":") if dest == "" { delete(drop_dests, mime) } else { path, err := filepath.Abs(dest) if err != nil { return 1, err } drop_dests[mime] = &drop_dest{human_name: dest, path: path, mime_type: mime} } } drag_sources := make(map[string]drag_source) for _, spec := range opts.Drag { mime, src, found := strings.Cut(spec, ":") if !found { return 1, fmt.Errorf("invalid drag source %s, must be of the form mime-type:path", spec) } s := drag_source{human_name: src, mime_type: mime} if src == "-" || src == "/dev/stdin" { s.human_name = "STDIN" s.file = os.Stdin } else { path, err := filepath.Abs(src) if err != nil { return 1, err } s.path = path } drag_sources[mime] = s } if _, has_plain := drag_sources["text/plain"]; os.Stdin != nil && !has_plain && !tty.IsTerminal(os.Stdin.Fd()) { data, err := io.ReadAll(os.Stdin) if err != nil { return 1, err } if len(data) > 0 { drag_sources["text/plain"] = drag_source{human_name: "STDIN", mime_type: "text/plain", data: data} } } var uri_list []uri_list_item for _, arg := range args { st, err := os.Stat(arg) if err != nil { return 1, err } if st.IsDir() || st.Mode().IsRegular() { path, err := filepath.Abs(arg) if err != nil { return 1, err } upath := filepath.ToSlash(path) if runtime.GOOS == "windows" && !strings.HasPrefix(upath, "/") { upath = "/" + upath } u := &url.URL{Scheme: "file", Path: upath} uri_list = append(uri_list, uri_list_item{path: path, uri: u.String(), human_name: arg}) } else { return 1, fmt.Errorf("%s is not a directory or regular file", arg) } } if len(uri_list) > 0 { uris := make([]string, len(uri_list)) for i, u := range uri_list { uris[i] = u.uri } payload := strings.Join(uris, "\r\n") + "\r\n" drag_sources["text/uri-list"] = drag_source{ human_name: "Files", mime_type: "text/uri-list", uri_list: uri_list, data: utils.UnsafeStringToBytes(payload), } } dnd := dnd{opts: opts, drop_dests: drop_dests, drag_sources: drag_sources} if err = dnd.run_loop(); err != nil { return 1, err } return 0, nil } func EntryPoint(parent *cli.Command) { create_cmd(parent, dnd_main) }