diff --git a/kittens/dnd/drop.go b/kittens/dnd/drop.go index ee75c9603..5fdf19425 100644 --- a/kittens/dnd/drop.go +++ b/kittens/dnd/drop.go @@ -362,7 +362,7 @@ func (d *remote_dir_entry) add_remote_data(data []byte, output_buf []byte, has_m 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, "#") { + if line == "" || strings.HasPrefix(line, "#") { continue } if !strings.HasPrefix(line, "file://") { diff --git a/kittens/dnd/main.go b/kittens/dnd/main.go index 0f12cccb9..950eeaad3 100644 --- a/kittens/dnd/main.go +++ b/kittens/dnd/main.go @@ -233,6 +233,8 @@ func (dnd *dnd) run_loop() (err error) { 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, "|")) + case "CONFIRM_PENDING": + dnd.send_test_response(utils.IfElse(len(dnd.confirm_drop.overwrites) > 0, "True", "False")) default: dnd.send_test_response("UNKNOWN TEST COMMAND: " + string(cmd.Payload)) } diff --git a/kitty_tests/dnd_kitten.py b/kitty_tests/dnd_kitten.py index c32e8aa2c..7b392771d 100644 --- a/kitty_tests/dnd_kitten.py +++ b/kitty_tests/dnd_kitten.py @@ -2,6 +2,7 @@ # License: GPLv3 Copyright: 2026, Kovid Goyal import fnmatch +import itertools import os import random import shutil @@ -63,9 +64,8 @@ class TestDnDKitten(BaseTest): def assert_trees_equal(self, a: str, b: str, ignored='.dnd-kitten-drop-*'): a_name = os.path.relpath(a, self.test_dir) b_name = os.path.relpath(b, self.test_dir) - def include(x): - return not fnmatch.fnmatch(x, ignored) - entries_a, entries_b = list(filter(include, os.listdir(a))), list(filter(include, os.listdir(b))) + entries_a = list(itertools.filterfalse(lambda x: fnmatch.fnmatch(x, ignored), os.listdir(a))) + entries_b = list(itertools.filterfalse(lambda x: fnmatch.fnmatch(x, ignored), os.listdir(b))) self.assertEqual(set(entries_a), set(entries_b), f'readdir() different for {a_name} vs {b_name}') for x in entries_a: ca, cb = os.path.join(a, x), os.path.join(b, x) @@ -183,6 +183,23 @@ class TestDnDKitten(BaseTest): self.pty.write_to_child('\x1b[27u') # ] self.pty.wait_till_child_exits(require_exit_code=0) + def wait_for_confirm_pending(self, timeout=10): + """Poll until the kitten reports that an overwrite confirmation is pending.""" + self.messages_from_kitten = '' + self.send_dnd_command_to_kitten('CONFIRM_PENDING', flush=True) + + def check(): + if self.messages_from_kitten.strip() == 'True': + self.messages_from_kitten = '' + return True + if self.messages_from_kitten.strip() == 'False': + # Not yet pending; ask again on the next iteration + self.messages_from_kitten = '' + self.send_dnd_command_to_kitten('CONFIRM_PENDING', flush=True) + return False + + self.pty.wait_till(check, timeout, lambda: 'Timed out waiting for overwrite confirmation to become pending') + def tearDown(self): dnd_set_test_write_func(None) dnd_test_cleanup_fake_window(self.capture.os_window_id) @@ -192,7 +209,7 @@ class TestDnDKitten(BaseTest): def test_dnd_kitten_drop(self): img_drop_path = 'images/image.png' - self.finish_setup(cli_args=(f'--drop=image/png:{img_drop_path}',)) + self.finish_setup(cli_args=(f'--drop=image/png:{img_drop_path}', '--confirm-drop-overwrite')) with self.subTest(remote=False): self.dnd_kitten_drop(False, img_drop_path) self.reset_kitten(True) @@ -232,7 +249,7 @@ class TestDnDKitten(BaseTest): self.assertEqual('text/uri-list\x00image/png', self.probe_state('drop_mimes').rstrip('\x00')) self.wait_for_state('drop_data_requests', ((1,0,0), (4,0,0))) self.assertEqual('text/uri-list', self.probe_state('drop_getting_data_for_mime')) - create_fs(self.src_data_dir) + create_fs(self.src_data_dir, include_toplevel_working_symlink=not remote_client) uri_list, path_list = [], [] for x in sorted(os.listdir(self.src_data_dir)): uri_list.append(as_file_url(self.src_data_dir, x)) @@ -271,6 +288,59 @@ class TestDnDKitten(BaseTest): self.wait_for_state('drop_action', 0) self.assert_trees_equal(self.src_data_dir, self.kitten_wd) + # ---- edge case: blank lines in text/uri-list are treated as empty entries ---- + # RFC 2483 allows blank lines; parse_uri_list should skip them cleanly. + with tempfile.NamedTemporaryFile(dir=self.src_data_dir, suffix='.txt', delete=False) as tf: + tf.write(b'edge case blank line test') + edge_file = tf.name + blank_line_uri_list = ('\r\n' + as_file_url(self.src_data_dir, os.path.basename(edge_file)) + '\r\n\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', blank_line_uri_list) + self.wait_for_state('drop_action', 0) + # The file should have been copied despite surrounding blank lines in the URI list. + self.assertTrue(os.path.exists(jn(self.kitten_wd, os.path.basename(edge_file))), + 'File from URI list with surrounding blank lines should be copied to destination') + os.unlink(edge_file) + + # ---- overwrite confirmation: Enter key allows the overwrite ---- + # kitten_wd already has 'some-image.png'; dropping the same filename again triggers confirmation. + enter_content = b'overwrite-enter-test-content' + with open(jn(self.src_data_dir, 'some-image.png'), 'wb') as f: + f.write(enter_content) + + overwrite_uri = (as_file_url(self.src_data_dir, 'some-image.png') + '\r\n').encode() + + def do_overwrite_drop(): + 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', overwrite_uri) + + do_overwrite_drop() + self.wait_for_confirm_pending() + self.pty.write_to_child('\x1b[13u') # Enter key: confirm overwrite + self.wait_for_state('drop_action', 0) + with open(jn(self.kitten_wd, 'some-image.png'), 'rb') as f: + self.assertEqual(f.read(), enter_content, + 'Enter key should have confirmed the overwrite') + + # ---- overwrite confirmation: Esc key cancels the overwrite ---- + esc_content = b'overwrite-esc-test-content' + with open(jn(self.src_data_dir, 'some-image.png'), 'wb') as f: + f.write(esc_content) + + do_overwrite_drop() + self.wait_for_confirm_pending() + self.pty.write_to_child('\x1b[27u') # Esc key: cancel overwrite + self.wait_for_state('last_drop_action', 0) + with open(jn(self.kitten_wd, 'some-image.png'), 'rb') as f: + self.assertEqual(f.read(), enter_content, + 'Esc key should have cancelled the overwrite; file must be unchanged') + 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)})')