diff --git a/kittens/dnd/drop.go b/kittens/dnd/drop.go index 3ec8a9607..03fcc2341 100644 --- a/kittens/dnd/drop.go +++ b/kittens/dnd/drop.go @@ -4,6 +4,7 @@ import ( "bytes" "container/list" "context" + "encoding/base64" "errors" "fmt" "io" @@ -359,21 +360,121 @@ func (d *remote_dir_entry) add_remote_data(data []byte, output_buf []byte, has_m return nil } -func parse_uri_list(src string) (ans []string, err error) { +type parsed_uri_kind int + +const ( + parsed_uri_file parsed_uri_kind = iota + parsed_uri_data +) + +type parsed_uri struct { + kind parsed_uri_kind + path string // for file URIs: the local filesystem path (empty if not a valid file URI) + mime string // for data URIs: the MIME type + data []byte // for data URIs: the decoded payload +} + +// ext_for_mime returns a file extension (with leading dot) for a MIME type. +func ext_for_mime(mime string) string { + switch mime { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "image/svg+xml": + return ".svg" + case "text/plain": + return ".txt" + case "text/html": + return ".html" + case "text/css": + return ".css" + case "application/pdf": + return ".pdf" + case "application/json": + return ".json" + case "application/zip": + return ".zip" + default: + if _, subtype, found := strings.Cut(mime, "/"); found && subtype != "" { + subtype, _, _ = strings.Cut(subtype, "+") + subtype, _, _ = strings.Cut(subtype, ";") + subtype = strings.TrimSpace(subtype) + if subtype != "" { + return "." + subtype + } + } + return "" + } +} + +// parse_data_uri decodes a data: URI and returns the MIME type and raw data. +func parse_data_uri(uri string) (mime string, data []byte, err error) { + rest := strings.TrimPrefix(uri, "data:") + comma_idx := strings.Index(rest, ",") + if comma_idx < 0 { + err = fmt.Errorf("invalid data URI: no comma found") + return + } + header := rest[:comma_idx] + payload := rest[comma_idx+1:] + + is_base64 := strings.HasSuffix(header, ";base64") + if is_base64 { + header = header[:len(header)-7] + } + + mime = strings.TrimSpace(header) + if mime == "" { + mime = "text/plain" + } + // Strip parameters for extension lookup but keep original for the field + mime_for_ext, _, _ := strings.Cut(mime, ";") + mime = strings.TrimSpace(mime_for_ext) + + if is_base64 { + payload = strings.NewReplacer("\r", "", "\n", "", " ", "").Replace(payload) + data, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + data, err = base64.RawURLEncoding.DecodeString(payload) + } + } else { + var unescaped string + unescaped, err = url.PathUnescape(payload) + if err != nil { + return + } + data = []byte(unescaped) + } + return +} + +func parse_uri_list(src string) (ans []parsed_uri, err error) { for _, line := range utils.NewSeparatorScanner("", "\r\n").Split(src) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } - if !strings.HasPrefix(line, "file://") { - ans = append(ans, "") - continue + if strings.HasPrefix(line, "file://") { + p, err := url.Parse(line) + if err != nil { + return nil, err + } + ans = append(ans, parsed_uri{kind: parsed_uri_file, path: filepath.Clean(p.Path)}) + } else if strings.HasPrefix(line, "data:") { + m, d, derr := parse_data_uri(line) + if derr != nil { + ans = append(ans, parsed_uri{kind: parsed_uri_file}) // treat invalid data URI as empty entry + } else { + ans = append(ans, parsed_uri{kind: parsed_uri_data, mime: m, data: d}) + } + } else { + ans = append(ans, parsed_uri{kind: parsed_uri_file}) // unknown URI scheme: empty entry } - p, err := url.Parse(line) - if err != nil { - return nil, err - } - ans = append(ans, filepath.Clean(p.Path)) } return } @@ -381,7 +482,7 @@ func parse_uri_list(src string) (ans []string, err error) { type drop_status struct { offered_mimes []string accepted_mimes []string - uri_list []string + uri_list []parsed_uri cell_x, cell_y int action int in_window bool @@ -455,18 +556,18 @@ func (dnd *dnd) end_drop(success bool) { dnd.lp.QueueDnDData(DC{ Type: 'r', Operation: utils.IfElse(success, dnd.drop_status.action, 0)}) // end drop if success && dnd.drop_status.action == move_on_drop && !dnd.drop_status.is_remote_client { - for _, path := range dnd.drop_status.uri_list { - if path == "" { + for _, u := range dnd.drop_status.uri_list { + if u.kind != parsed_uri_file || u.path == "" { continue } - st, err := os.Lstat(path) + st, err := os.Lstat(u.path) if err != nil { continue } if st.IsDir() { - os.RemoveAll(path) + os.RemoveAll(u.path) } else { - os.Remove(path) + os.Remove(u.path) } } } @@ -551,21 +652,34 @@ func (dnd *dnd) all_mime_data_dropped() (err error) { return err } dnd.drop_status.dropping_to = new_dir_handle(f) + + // Write data URIs directly into the staging directory (applies to both local and remote). + data_uri_counter := 0 + for _, u := range drop_status.uri_list { + if u.kind == parsed_uri_data { + data_uri_counter++ + fname := fmt.Sprintf("data-uri-%d%s", data_uri_counter, ext_for_mime(u.mime)) + if werr := os.WriteFile(filepath.Join(f.Name(), fname), u.data, 0o644); werr != nil { + return werr + } + } + } + if drop_status.is_remote_client { drop_status.root_remote_dir = &remote_dir_entry{} 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 { + for i, u := range drop_status.uri_list { var c *remote_dir_entry - if x == "" { + if u.kind == parsed_uri_data || u.path == "" { c = &remote_dir_entry{} drop_status.root_remote_dir.num_children_finished++ } else { - name := filepath.Base(x) + name := filepath.Base(u.path) 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)) + name = fmt.Sprintf(case_conflict_template, q+1, filepath.Base(u.path)) key = strings.ToLower(name) } seen.Add(key) @@ -575,11 +689,22 @@ func (dnd *dnd) all_mime_data_dropped() (err error) { } drop_status.root_remote_dir.children = append(drop_status.root_remote_dir.children, c) } + // If all children were pre-finished (e.g., all data URIs), complete the drop immediately. + if drop_status.root_remote_dir.num_children_finished >= len(drop_status.root_remote_dir.children) { + return dnd.all_drop_data_received() + } drop_status.open_remote_dir = drop_status.root_remote_dir } else { + // Build a file-path slice for the goroutine; data URI entries are already written above. + file_paths := make([]string, len(drop_status.uri_list)) + for i, u := range drop_status.uri_list { + if u.kind == parsed_uri_file { + file_paths[i] = u.path + } + } drop_status.local_copy.ctx, drop_status.local_copy.cancel_ctx = context.WithCancel(context.Background()) drop_status.local_copy.completion = make(chan error, 1) - go do_local_copy_in_goroutine(drop_status.local_copy.ctx, f, drop_status.local_copy.completion, slices.Clone(drop_status.uri_list), func() { dnd.lp.WakeupMainThread() }) + go do_local_copy_in_goroutine(drop_status.local_copy.ctx, f, drop_status.local_copy.completion, file_paths, func() { dnd.lp.WakeupMainThread() }) } return } diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index 3bf51a397..b6bbf6867 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -243,7 +243,11 @@ func (dnd *dnd) run_loop() (err error) { case "DROP_IS_REMOTE": dnd.send_test_response(utils.IfElse(dnd.drop_status.is_remote_client, "True", "False")) case "DROP_URI_LIST": - dnd.send_test_response(strings.Join(dnd.drop_status.uri_list, "|")) + paths := make([]string, len(dnd.drop_status.uri_list)) + for i, u := range dnd.drop_status.uri_list { + paths[i] = u.path + } + dnd.send_test_response(strings.Join(paths, "|")) case "DRAG_ACTIVE": dnd.send_test_response(utils.IfElse(dnd.drag_status.active, "DRAG_ACTIVE", "DRAG_INACTIVE")) case "DRAG_OK": diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index 0fe76caec..03a044672 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -358,6 +358,33 @@ def do_overwrite_drop(src_content, key_press, action=GLFW_DRAG_OPERATION_COPY): self.assertFalse(os.path.exists(move_dir), 'local move: source directory must be deleted') self.assertFalse(os.path.lexists(move_link), 'local move: source symlink must be deleted') + # ---- data URI: inline data should be written to data-uri-N.ext in the destination ---- + data_uri_content_1 = b'data uri plain text content' + data_uri_b64 = standard_b64encode(data_uri_content_1).decode() + data_uri_content_2 = b'\x89PNG\r\n\x1a\n' + b'\x00' * 8 # fake PNG bytes + data_uri_b64_2 = standard_b64encode(data_uri_content_2).decode() + data_uri_list = ( + f'data:text/plain;base64,{data_uri_b64}\r\n' + f'data:image/png;base64,{data_uri_b64_2}\r\n' + ).encode() + dnd_test_fake_drop_event(self.capture.window_id, False, ['text/uri-list'], copy[0]+1, copy[1]+1) + self.wait_for_state('drop_action', GLFW_DRAG_OPERATION_COPY) + dnd_test_fake_drop_event(self.capture.window_id, True, ['text/uri-list'], copy[0]+1, copy[1]+1) + self.wait_for_state('drop_data_requests', ((1, 0, 0),)) + dnd_test_fake_drop_data(self.capture.window_id, 'text/uri-list', data_uri_list) + self.wait_for_state('last_drop_action', GLFW_DRAG_OPERATION_COPY) + self.wait_for_state('drop_action', 0) + data_uri_file_1 = jn(self.kitten_wd, 'data-uri-1.txt') + data_uri_file_2 = jn(self.kitten_wd, 'data-uri-2.png') + self.assertTrue(os.path.exists(data_uri_file_1), f'data URI file 1 should be created at {data_uri_file_1}') + self.assertTrue(os.path.exists(data_uri_file_2), f'data URI file 2 should be created at {data_uri_file_2}') + with open(data_uri_file_1, 'rb') as f: + self.assertEqual(data_uri_content_1, f.read()) + with open(data_uri_file_2, 'rb') as f: + self.assertEqual(data_uri_content_2, f.read()) + os.unlink(data_uri_file_1) + os.unlink(data_uri_file_2) + def assert_files_have_same_content(self, a, b): with open(a, 'rb') as fa, open(b, 'rb') as fb: self.assertEqual(fa.read(), fb.read(), f'{a} ({os.path.getsize(a)}) != {b} ({os.path.getsize(b)})')