diff --git a/docs/dnd-protocol.rst b/docs/dnd-protocol.rst index 8e2ba929d..b422b06f4 100644 --- a/docs/dnd-protocol.rst +++ b/docs/dnd-protocol.rst @@ -419,11 +419,6 @@ adding ``Y=parent-handle:y=num`` to the escape codes above. Here is the one based index into the list of entries in the directory. Thus, the set of keys ``x, y, Y`` uniquely determine an entry. -Once all data for a dirctory is transmitted, the client informs the terminal emulator of -completion with:: - - OSC _dnd_code ; t=k:Y=handle ; ST - If any error occurs in the client while reading the data, it can inform the terminal using:: diff --git a/kitty/dnd.c b/kitty/dnd.c index 4652e2eb5..ae06b8d69 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -1638,6 +1638,30 @@ drag_free_data(Window *w, const char *mime_type, const char* data, size_t sz) { return 0; } +static bool +is_file_url(const char *url) { + return url != NULL && strlen(url) > sizeof("file://")-1 && memcmp(url, "file://", sizeof("file://")-1) == 0; +} + +static bool +request_remote_files(Window *w, size_t i) { +#define mi ds.items[i] + char buf[128]; + mi.remote_items = calloc(mi.num_uris, sizeof(mi.remote_items[0])); + if (!mi.remote_items) return false; + mi.num_remote_items = mi.num_uris; + for (size_t k = 0; k < mi.num_remote_items; k++) { + if (is_file_url(mi.uri_list[k])) { + int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=k:x=%zu", DND_CODE, k + 1); + queue_payload_to_child( + w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); + mi.remote_items[k].waiting_for_completion = true; + } + } + return true; +#undef mi +} + const char* drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) { *err_code = ENOENT; *sz = 0; @@ -1693,14 +1717,14 @@ drag_get_data(Window *w, const char *mime_type, size_t *sz, int *err_code) { } // No fd yet, request data from the client if (!ds.items[i].data_requested_from_client) { - char buf[128]; ds.items[i].data_requested_from_client = true; ds.items[i].requested_remote_files = ds.is_remote_client && ds.items[i].is_uri_list; if (ds.items[i].requested_remote_files) { - // TODO: send remote file requests + if (!request_remote_files(w, i)) { *err_code = ENOMEM; return NULL; } } else { - int header_sz = snprintf(buf, sizeof(buf), "\x1b]%d;t=e:x=%d:y=%zu:Y=%d", - DND_CODE, DRAG_NOTIFY_FINISHED + 2, i, ds.items[i].requested_remote_files); + char buf[128]; + int header_sz = snprintf(buf, sizeof(buf), + "\x1b]%d;t=e:x=%d:y=%zu", DND_CODE, DRAG_NOTIFY_FINISHED + 2, i); queue_payload_to_child( w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_sz, NULL, 0, false); } @@ -1919,10 +1943,12 @@ populate_dir_entries(Window *w, DragRemoteItem *ri) { while (ptr < end) { const char *p = memchr(ptr, 0, (size_t)(end - ptr)); size_t len = p ? (size_t)(p - ptr) : (size_t)(end - ptr); + DragRemoteItem *child = ri->children + ri->children_sz++; + child->parent = ri; if (len > 0) { char *name = strndup(ptr, len); if (!name) abrt(ENOMEM, "out of memory processing drag source item directory entries"); - ri->children[ri->children_sz++].dir_entry_name = name; + child->dir_entry_name = name; } ptr = p ? p + 1 : end; } @@ -1996,11 +2022,6 @@ toplevel_data_for_drag( Window *w, unsigned mime_item_idx, unsigned uri_item_idx, unsigned item_type, bool has_more, const uint8_t *payload, size_t payload_sz ) { - if (!mi.remote_items) { - mi.remote_items = calloc(mi.num_uris, sizeof(mi.remote_items[0])); - if (!mi.remote_items) abrt(ENOMEM, "out of memory processing drag source item"); - mi.num_remote_items = mi.num_uris; - } if (!mi.base_dir_for_remote_items) { int fd; mi.base_dir_for_remote_items = mktempdir_in_cache("dnd-drag-", &fd); @@ -2050,10 +2071,10 @@ find_by_handle(DragRemoteItem *parent, int handle, char *path_to_parent, size_t static void subdir_data_for_drag( Window *w, unsigned mime_item_idx, unsigned uri_item_idx, int handle, unsigned entry_num, unsigned item_type, - bool has_more, const uint8_t *payload, size_t payload_sz + bool has_more, const uint8_t *payload, size_t payload_sz, DragRemoteItem **ri ) { if (!mi.remote_items || uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source sub directory item uri list index out of range"); - DragRemoteItem *parent = NULL; + DragRemoteItem *parent = NULL; *ri = NULL; if (mi.currently_open_subdir) { if (mi.currently_open_subdir->type == handle) parent = mi.currently_open_subdir; else { @@ -2080,15 +2101,14 @@ subdir_data_for_drag( } } if (entry_num >= parent->children_sz) abrt(EINVAL, "drag source sub diretory index out of bounds"); - DragRemoteItem *ri = parent->children + entry_num; - if (!ri->started) { - ri->started = true; - ri->type = item_type; - base64_init_stream_decoder(&ri->base64_state); + *ri = parent->children + entry_num; + if (!(*ri)->started) { + (*ri)->started = true; + (*ri)->type = item_type; + base64_init_stream_decoder(&(*ri)->base64_state); } - add_payload(w, ri, has_more, payload, payload_sz, parent->fd_plus_one - 1); + add_payload(w, *ri, has_more, payload, payload_sz, parent->fd_plus_one - 1); } -#undef mi void drag_offer_start_to_child(Window *w, int32_t cell_x, int32_t cell_y, int32_t pixel_x, int32_t pixel_y) { @@ -2099,25 +2119,69 @@ drag_offer_start_to_child(Window *w, int32_t cell_x, int32_t cell_y, int32_t pix w->id, w->drag_source.client_id, &w->drag_source.pending, buf, header_size, NULL, 0, false); } +static void +finish_remote_data_if_all_items_received(Window *w, unsigned mime_item_idx) { + for (size_t i = 0; i < mi.num_remote_items; i++) { + if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) return; + } + finish_remote_data(w, mime_item_idx); +} + +static bool +all_children_complete(DragRemoteItem *parent) { + for (size_t i = 0; i < parent->children_sz; i++) { + if (!parent->children[i].completed) return false; + } + return true; +} + void drag_remote_file_data( Window *w, int32_t x, int32_t y, int32_t X, int32_t Y, bool has_more, const uint8_t *payload, size_t payload_sz ) { - size_t item_idx = ds.num_mimes; + size_t mime_item_idx = ds.num_mimes; for (size_t i = 0; i < ds.num_mimes; i++) { if (ds.items[i].requested_remote_files) { - item_idx = i; break; + mime_item_idx = i; break; + } + } + if (mime_item_idx == ds.num_mimes) abrt(EINVAL, "drag source no text/uri-list MIME entry data was requested"); + if (x < 1) abrt(EINVAL, "drag source remote item x index cannot be less than 1"); + const bool all_data_received = !payload_sz && !has_more; + const unsigned uri_item_idx = x - 1; + if (uri_item_idx >= mi.num_remote_items) abrt(EINVAL, "drag source uri list index out of bounds"); + DragRemoteItem *ri; + if (!Y) { + toplevel_data_for_drag(w, mime_item_idx, uri_item_idx, X, has_more, payload, payload_sz); + if (all_data_received) { + ri = mi.remote_items + uri_item_idx; + if (ri->waiting_for_completion && all_children_complete(ri)) { + ri->completed = true; + finish_remote_data_if_all_items_received(w, mime_item_idx); + } + } + } else { + if (y < 1) abrt(EINVAL, "drag source remote item y index cannot be less than 1"); + subdir_data_for_drag(w, mime_item_idx, x - 1, Y, y - 1, X, has_more, payload, payload_sz, &ri); + if (all_data_received && ri && all_children_complete(ri)) { + ri->completed = true; + while (1) { + ri = ri->parent; + if (ri) { + if (all_children_complete(ri)) ri->completed = true; + else break; + } else { + finish_remote_data_if_all_items_received(w, mime_item_idx); + break; + } + } } } - if (item_idx == ds.num_mimes) abrt(EINVAL, "drag source remote file item index out of bounds"); - if (X < 0) abrt(EINVAL, "drag source remote item X cannot be negative"); - if (!x && !y && !Y) { finish_remote_data(w, item_idx); return; } - if (!Y) toplevel_data_for_drag(w, item_idx, x - 1, X, has_more, payload, payload_sz); - else subdir_data_for_drag(w, item_idx, x - 1, Y, y - 1, X, has_more, payload, payload_sz); } #undef img #undef abrt #undef ds +#undef mi // }}} // DnD testing infrastructure {{{ @@ -2316,8 +2380,7 @@ dnd_test_force_drag_dropped(PyObject *self UNUSED, PyObject *args) { static PyObject * dnd_test_request_drag_data(PyObject *self UNUSED, PyObject *args) { // Simulate what drag_get_data does initially: find the MIME item at the - // given index, set requested_remote_files if appropriate, and return the - // escape code that would be sent to the client. + // given index, set requested_remote_files if appropriate. unsigned long long window_id; unsigned idx; if (!PyArg_ParseTuple(args, "KI", &window_id, &idx)) return NULL; @@ -2327,6 +2390,7 @@ dnd_test_request_drag_data(PyObject *self UNUSED, PyObject *args) { PyErr_SetString(PyExc_ValueError, "Invalid state or index"); return NULL; } w->drag_source.items[idx].requested_remote_files = w->drag_source.is_remote_client && w->drag_source.items[idx].is_uri_list; + if (w->drag_source.items[idx].requested_remote_files) request_remote_files(w, idx); Py_RETURN_NONE; } @@ -2424,6 +2488,19 @@ dnd_test_probe_state(PyObject *self UNUSED, PyObject *args) { if (strcmp(q, "drag_thumbnail_size") == 0) { return PyLong_FromSize_t(last_total_image_size); } + if (strcmp(q, "drag_remote_data_complete") == 0) { + for (size_t idx = 0; idx < w->drag_source.num_mimes; idx++) { +#define mi w->drag_source.items[idx] + if (mi.is_uri_list && mi.requested_remote_files) { + for (size_t i = 0; i < mi.num_remote_items; i++) { + if (mi.remote_items[i].waiting_for_completion && !mi.remote_items[i].completed) + return PyUnicode_FromString(mi.remote_items[i].dir_entry_name); + } + } +#undef mi + } + Py_RETURN_NONE; + } Py_RETURN_NONE; } diff --git a/kitty/state.h b/kitty/state.h index 0a9afc2fb..a4e65a49d 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -253,7 +253,8 @@ typedef struct DragRemoteItem { size_t children_sz; char *dir_entry_name; base64_state base64_state; - bool started; + bool started, waiting_for_completion, completed; + struct DragRemoteItem *parent; } DragRemoteItem; typedef struct Window { diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index 089f2a446..9381c4c1c 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -18,6 +18,7 @@ from kitty.fast_data_types import ( dnd_test_fake_drop_data, dnd_test_fake_drop_event, dnd_test_force_drag_dropped, + dnd_test_probe_state, dnd_test_request_drag_data, dnd_test_set_mouse_pos, ) @@ -236,14 +237,6 @@ def client_remote_file( return _osc(meta) -def client_remote_file_finish(client_id: int = 0) -> bytes: - """Escape code signaling completion of all remote file data (t=k with no keys).""" - meta = f'{DND_CODE};t=k' - if client_id: - meta += f':i={client_id}' - return _osc(meta) - - # ---- escape-code decoder used by assertions --------------------------------- _OSC_RE = re.compile( @@ -2342,11 +2335,17 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_drag_offer_mimes(operations, mimes, client_id=client_id)) mime_list = mimes.split() uri_idx = mime_list.index('text/uri-list') - b64 = standard_b64encode(uri_list_data).decode().rstrip('=') + b64 = standard_b64encode(uri_list_data).decode() parse_bytes(screen, client_drag_pre_send(uri_idx, b64, client_id=client_id)) cap.consume() dnd_test_force_drag_dropped(cap.window_id) dnd_test_request_drag_data(cap.window_id, uri_idx) + events = self._get_events(cap) + expected = 0 + for line in uri_list_data.decode().splitlines(): + if line.startswith('file://'): + expected += 1 + self.assertEqual(expected, len(events)) def test_remote_drag_single_file(self) -> None: """Transfer a single regular file via t=k.""" @@ -2361,9 +2360,7 @@ class TestDnDProtocol(BaseTest): # End of data for this file parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_single_symlink(self) -> None: """Transfer a symlink via t=k with X=1.""" @@ -2378,9 +2375,7 @@ class TestDnDProtocol(BaseTest): # End of data parse_bytes(screen, client_remote_file(1, '', item_type=1)) self._assert_no_output(cap) - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_single_directory(self) -> None: """Transfer a directory with entries via t=k with X=handle (>1).""" @@ -2417,10 +2412,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=0, parent_handle=2, entry_num=2)) self._assert_no_output(cap) - - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_multiple_uris(self) -> None: """Transfer multiple files from a URI list.""" @@ -2437,9 +2429,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(2, b64, item_type=0)) parse_bytes(screen, client_remote_file(2, '', item_type=0)) self._assert_no_output(cap) - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_chunked_file(self) -> None: """File data can be sent in multiple chunks with m=1.""" @@ -2463,9 +2453,7 @@ class TestDnDProtocol(BaseTest): # End of data parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_directory_with_symlink(self) -> None: """Directory can contain symlinks (X=1 type for children).""" @@ -2494,10 +2482,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=1, parent_handle=2, entry_num=2)) self._assert_no_output(cap) - - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_deep_directory_tree_breadth_first(self) -> None: """Transfer a 3-level deep directory tree in breadth-first order. @@ -2567,10 +2552,7 @@ class TestDnDProtocol(BaseTest): 1, b64, item_type=1, parent_handle=4, entry_num=2)) parse_bytes(screen, client_remote_file( 1, '', item_type=1, parent_handle=4, entry_num=2)) - - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_deep_directory_tree_depth_first(self) -> None: """Transfer a 3-level deep directory tree in depth-first order. @@ -2640,8 +2622,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=1, parent_handle=4, entry_num=2)) - # Completion - parse_bytes(screen, client_remote_file_finish()) + self.assert_drag_data_complete(cap) self._assert_no_output(cap) def test_remote_drag_completion_signal(self) -> None: @@ -2652,8 +2633,7 @@ class TestDnDProtocol(BaseTest): b64 = standard_b64encode(b'data').decode() parse_bytes(screen, client_remote_file(1, b64, item_type=0)) parse_bytes(screen, client_remote_file(1, '', item_type=0)) - # Completion - parse_bytes(screen, client_remote_file_finish()) + self.assert_drag_data_complete(cap) self._assert_no_output(cap) def test_remote_drag_invalid_uri_index(self) -> None: @@ -2721,15 +2701,6 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(1, big_b64, item_type=0)) self.assert_error(cap) - def test_remote_drag_negative_X_rejected(self) -> None: - """Sending t=k with X < 0 is rejected.""" - uri_list = b'file:///home/user/f.txt\r\n' - with dnd_test_window() as (screen, cap): - self._setup_remote_drag(screen, cap, uri_list) - # Directly construct escape code with negative X - parse_bytes(screen, _osc(f'{DND_CODE};t=k:x=1:X=-1')) - self.assert_error(cap) - def test_remote_drag_without_remote_flag_fails(self) -> None: """t=k fails if the drag is not from a remote client.""" with dnd_test_window() as (screen, cap): @@ -2796,6 +2767,10 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(1, b64, item_type=0)) self.assert_error(cap) + def assert_drag_data_complete(self, cap) -> None: + first_incomplete_entry = dnd_test_probe_state(cap.window_id, 'drag_remote_data_complete') + self.assertIsNone(first_incomplete_entry) + def test_remote_drag_three_level_tree_with_verification(self) -> None: """Transfer a 3-level directory tree and verify no errors occur. @@ -2870,9 +2845,7 @@ class TestDnDProtocol(BaseTest): 1, '', item_type=1, parent_handle=30, entry_num=2)) self._assert_no_output(cap) - # Completion - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_process_item_data_basic(self) -> None: """Basic drag_process_item_data: send data for a MIME type after DROPPED state.""" @@ -2949,8 +2922,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file(3, '', item_type=1)) self._assert_no_output(cap) - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_empty_file(self) -> None: """Transfer an empty file (end-of-data immediately after start).""" @@ -2960,8 +2932,7 @@ class TestDnDProtocol(BaseTest): # Start file transfer, then immediately end (no data chunks) parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_empty_directory(self) -> None: """Transfer a directory with no entries.""" @@ -2972,8 +2943,7 @@ class TestDnDProtocol(BaseTest): b64 = standard_b64encode(b'').decode() parse_bytes(screen, client_remote_file(1, b64, item_type=2)) parse_bytes(screen, client_remote_file(1, '', item_type=2)) - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) def test_remote_drag_uri_list_with_comments(self) -> None: """URI list with comment lines (starting with #) should filter them out.""" @@ -3022,9 +2992,7 @@ class TestDnDProtocol(BaseTest): parse_bytes(screen, client_remote_file( 1, '', item_type=0, parent_handle=2, entry_num=2)) self._assert_no_output(cap) - - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) + self.assert_drag_data_complete(cap) # ---- DoS limits tests --------------------------------------------------- @@ -3132,9 +3100,6 @@ class TestDnDProtocol(BaseTest): # End of data for this file parse_bytes(screen, client_remote_file(1, '', item_type=0)) self._assert_no_output(cap) - # Completion signal - parse_bytes(screen, client_remote_file_finish()) - self._assert_no_output(cap) # No crash or leak - cleanup happens in context manager exit # ---- query tests --------------------------------------------------------