collective

Browse / github.com/storytold/pdfcraft / patch

fix(ui): support drag and drop file URIs on Combine files page (#4985)

Drag and drop not working in "Combine files" page This page says "Drop PDFs, images, text files or folders here, or choose them." <img width="1311" height="739" alt="Image" src="https://github.com/user-attachments/assets/66d98954-768f-4863-8504-3fbd664fb74d" /> But nothing happens when I drop a PDF from file manager. OS: Kubuntu 26.04 Desktop Environment: KDE 6.6.6

Status
open
Score
0.00
Adopters
0 other operators
Author
YusufMntm (imported from pull request #5007, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+297 −28 in 6 files
Review
pending
Shared
2026-10-28
Id
b2980c8f035aba774cb4cb82

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/app.rs crates/ui-egui/src/app/input.rs crates/ui-egui/src/workflows/combine_ui.rs crates/ui-egui/src/workflows/combine_ui/parts.rs crates/ui-egui/src/workflows/files.rs crates/ui-egui/tests/it/combine_grid.rs

diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -393,11 +393,11 @@ impl eframe::App for PdfCraftApp {         let dropped = self.drop_on_signature_pad(dropped);         #[cfg(not(target_arch = "wasm32"))]         let dropped = self.drop_on_grid(dropped, ctx);-        for f in dropped {-            // Files dropped on the Combine files tab join its list instead of opening.-            if self.combine_showing() {-                self.drop_into_combine(f, ctx);-            } else {+        // Files dropped on the Combine files tab join its list in batch instead of opening individually.+        if self.combine_showing() {+            self.drop_into_combine(&dropped, ctx);+        } else {+            for f in dropped {                 self.open_dropped(f, ctx);             }         }diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs--- a/crates/ui-egui/src/app/input.rs+++ b/crates/ui-egui/src/app/input.rs@@ -10,8 +10,13 @@ fn read_grid_file((name, source): GridFile) -> Result<(String, Vec<u8>), (String }  impl PdfCraftApp {+    /// Open a file dropped onto the application window outside of special drop targets.     #[cfg(not(target_arch = "wasm32"))]     pub(super) fn open_dropped(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) {+        if let Some(clean_path) = crate::workflows::files::clean_dropped_path(f.path()) {+            self.open_path(&clean_path.to_string_lossy());+            return;+        }         let p = f.path().to_string_lossy().into_owned();         if !p.is_empty() && f.path().is_absolute() {             self.open_path(&p);diff --git a/crates/ui-egui/src/workflows/combine_ui.rs b/crates/ui-egui/src/workflows/combine_ui.rs--- a/crates/ui-egui/src/workflows/combine_ui.rs+++ b/crates/ui-egui/src/workflows/combine_ui.rs@@ -28,11 +28,11 @@ pub(crate) use files::Incoming; pub use files::{Columns, CombineFile, CombineView, ConflictPreview, Lock, SortKey}; pub(crate) use files::{Preview, RowAction}; use files::{Snapshot, UnlockPrompt};-#[cfg(not(target_arch = "wasm32"))]-pub(crate) use parts::ago; pub(crate) use parts::range_of; #[cfg(test)] pub(crate) use parts::{Part, moved_order, moved_pages, removed_pages, step_to};+#[cfg(not(target_arch = "wasm32"))]+pub(crate) use parts::{ago, combinable_in}; #[cfg(test)] use parts::{title_of, utc_stamp}; pub(crate) use view::page;@@ -45,7 +45,7 @@ const HISTORY: usize = 100;  /// Files found in a folder are added up to this many at a time. #[cfg(not(target_arch = "wasm32"))]-const MAX_FOLDER_FILES: usize = 1000;+pub(crate) const MAX_FOLDER_FILES: usize = 1000; /// How deep "Add folder and subfolders" looks. #[cfg(not(target_arch = "wasm32"))] const MAX_FOLDER_DEPTH: usize = 16;diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs--- a/crates/ui-egui/src/workflows/combine_ui/parts.rs+++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs@@ -3,7 +3,7 @@ use super::*; /// The PDFs, images and text files in `dir` (and, if `recursive`, the folders inside it, not /// following links), in natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. #[cfg(not(target_arch = "wasm32"))]-pub(super) fn combinable_in(dir: &std::path::Path, recursive: bool) -> (Vec<std::path::PathBuf>, bool) {+pub(crate) fn combinable_in(dir: &std::path::Path, recursive: bool) -> (Vec<std::path::PathBuf>, bool) {     let mut found = Vec::new();     let mut more = false;     let mut folders = vec![(dir.to_path_buf(), 0usize)];diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs--- a/crates/ui-egui/src/workflows/files.rs+++ b/crates/ui-egui/src/workflows/files.rs@@ -394,13 +394,17 @@ impl PdfCraftApp {                 std::fs::read(p)             };             match read {-                Ok(b) => files.push((name, b)),+                Ok(b) => {+                    files.push((name, b));+                    modified.push(std::fs::metadata(p).and_then(|m| m.modified()).ok());+                }                 Err(e) => {                     self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]);-                    return;+                    if purpose != FilePurpose::Combine {+                        return;+                    }                 }             }-            modified.push(std::fs::metadata(p).and_then(|m| m.modified()).ok());         }         if files.is_empty() {             return;@@ -418,37 +422,88 @@ impl PdfCraftApp {         }     } -    /// A file dropped on the Combine files tab: added to its list.+    /// Files dropped on the Combine files tab: added to its list in batch, preserving drop order.     #[cfg(not(target_arch = "wasm32"))]-    pub(crate) fn drop_into_combine(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) {-        // A folder: the PDFs in it and in the folders inside it.-        if f.path().is_absolute() && f.path().is_dir() {-            self.add_folder_to_combine(f.path(), true);+    pub(crate) fn drop_into_combine(&mut self, dropped: &[egui::DroppedFileHandle], _ctx: &egui::Context) {+        if dropped.is_empty() {             return;         }-        if f.path().is_absolute() {-            self.use_paths(FilePurpose::Combine, &[f.path().to_path_buf()]);-            return;+        let mut incoming = Vec::new();+        for f in dropped {+            if let Some(clean_path) = clean_dropped_path(f.path()) {+                if clean_path.is_dir() {+                    let (folder_paths, more) = crate::workflows::combine_ui::combinable_in(&clean_path, true);+                    let folder =+                        clean_path.file_name().map_or_else(|| clean_path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned());+                    if folder_paths.is_empty() {+                        self.notify_fmt("No PDFs, images or text files found in {folder}", &[("folder", &folder)]);+                    } else {+                        for p in folder_paths {+                            let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file.pdf".into());+                            match std::fs::read(&p) {+                                Ok(bytes) => {+                                    let modified = std::fs::metadata(&p).and_then(|m| m.modified()).ok();+                                    incoming.push(crate::combine_ui::Incoming { name, bytes: Arc::new(bytes), modified, note: None, open_doc: None });+                                }+                                Err(e) => {+                                    self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]);+                                }+                            }+                        }+                        if more {+                            self.notify_fmt(+                                "Added the first {n} files in {folder}",+                                &[("n", &crate::workflows::combine_ui::MAX_FOLDER_FILES.to_string()), ("folder", &folder)],+                            );+                        }+                    }+                } else {+                    let name = clean_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file.pdf".into());+                    match std::fs::read(&clean_path) {+                        Ok(bytes) => {+                            let modified = std::fs::metadata(&clean_path).and_then(|m| m.modified()).ok();+                            incoming.push(crate::combine_ui::Incoming { name, bytes: Arc::new(bytes), modified, note: None, open_doc: None });+                        }+                        Err(e) => {+                            self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]);+                        }+                    }+                }+                continue;+            }+            let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());+            match f.bytes() {+                Ok(bytes) => incoming.push(crate::combine_ui::Incoming { name, bytes: Arc::new(bytes), modified: None, note: None, open_doc: None }),+                Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),+            }         }-        let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());-        match f.bytes() {-            Ok(bytes) => self.use_files(FilePurpose::Combine, vec![(name, bytes)]),-            Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),+        if !incoming.is_empty() {+            self.stage_combine_with(incoming);         }     }      /// Browsers read dropped files asynchronously; they join the Combine files list next frame.     #[cfg(target_arch = "wasm32")]-    pub(crate) fn drop_into_combine(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) {+    pub(crate) fn drop_into_combine(&mut self, dropped: &[egui::DroppedFileHandle], ctx: &egui::Context) {+        if dropped.is_empty() {+            return;+        }+        let dropped = dropped.to_vec();         let requests = self.requests.clone();         let request = self.file_request(FilePurpose::Combine, Vec::new());         let ctx = ctx.clone();         wasm_bindgen_futures::spawn_local(async move {-            let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());-            if let Ok(bytes) = f.bytes_async().await+            let mut files = Vec::with_capacity(dropped.len());+            for f in dropped {+                let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());+                if let Ok(bytes) = f.bytes_async().await {+                    files.push((name, bytes));+                }+            }+            if !files.is_empty()                 && let Ok(mut q) = requests.lock()             {-                q.push(FileRequest { files: vec![(name, bytes)], ..request });+                q.push(FileRequest { files, ..request });                 ctx.request_repaint();             }         });@@ -1062,6 +1117,88 @@ impl PdfCraftApp {     } } +/// Resolve a dropped file path, stripping `file://` URI schemes and percent-decoding+/// paths sent by Linux desktop environments (e.g. KDE Plasma / Dolphin) and other file managers.+pub(crate) fn clean_dropped_path(raw: &std::path::Path) -> Option<std::path::PathBuf> {+    if raw.is_absolute() {+        let s = raw.to_string_lossy();+        let lower = s.to_ascii_lowercase();+        if !lower.starts_with("file:") {+            return Some(raw.to_path_buf());+        }+    }+    let s = raw.to_string_lossy();+    let s_no_lines = s.trim_end_matches(|c| c == '\r' || c == '\n');+    let lower = s_no_lines.to_ascii_lowercase();+    let cleaned = if let Some(rest) = lower.strip_prefix("file://") {+        let after_host = if rest.starts_with("localhost/") {+            s_no_lines.get("file://localhost".len()..).unwrap_or("")+        } else {+            s_no_lines.get("file://".len()..).unwrap_or("")+        };+        decode_uri_path(after_host)+    } else if lower.starts_with("file:/") {+        let after_scheme = s_no_lines.get("file:".len()..).unwrap_or("");+        decode_uri_path(after_scheme)+    } else {+        s_no_lines.to_string()+    };++    if cleaned.is_empty() {+        return None;+    }+    let p = std::path::PathBuf::from(cleaned);+    if p.is_absolute() || raw.is_absolute() { Some(p) } else { None }+}++/// Decode percent-encoded byte sequences and strip any leading slash preceding Windows drive letters.+fn decode_uri_path(s: &str) -> String {+    let bytes = percent_decode(s);+    let decoded = String::from_utf8_lossy(&bytes).into_owned();+    if decoded.len() >= 3+        && (decoded.starts_with('/') || decoded.starts_with('\\'))+        && decoded.as_bytes().get(1).is_some_and(|b| b.is_ascii_alphabetic())+        && decoded.as_bytes().get(2) == Some(&b':')+    {+        decoded.get(1..).unwrap_or("").to_string()+    } else {+        decoded+    }+}++/// Decode percent-encoded byte sequences (e.g. `%20` -> ` `).+fn percent_decode(s: &str) -> Vec<u8> {+    let bytes = s.as_bytes();+    let mut out = Vec::with_capacity(bytes.len());+    let mut i = 0;+    while i < bytes.len() {+        if bytes.get(i) == Some(&b'%')+            && i + 2 < bytes.len()+            && let (Some(&b1), Some(&b2)) = (bytes.get(i + 1), bytes.get(i + 2))+            && let (Some(hi), Some(lo)) = (hex_val(b1), hex_val(b2))+        {+            out.push((hi << 4) | lo);+            i += 3;+        } else if let Some(&b) = bytes.get(i) {+            out.push(b);+            i += 1;+        } else {+            break;+        }+    }+    out+}++/// Convert an ASCII byte representing a hexadecimal digit (0-9, a-f, A-F) to its numerical value.+fn hex_val(b: u8) -> Option<u8> {+    match b {+        b'0'..=b'9' => Some(b - b'0'),+        b'a'..=b'f' => Some(b - b'a' + 10),+        b'A'..=b'F' => Some(b - b'A' + 10),+        _ => None,+    }+}+ #[cfg(test)] mod stem_tests {     use super::{attachment_file_name, file_stem_from_user, is_plain_file_name};@@ -1151,6 +1288,35 @@ mod stem_tests {         let _ = std::fs::remove_dir_all(&dir);     } +    #[test]+    fn clean_dropped_path_decodes_file_uris_and_percent_encoded_characters() {+        use super::{clean_dropped_path, percent_decode};+        assert_eq!(String::from_utf8(percent_decode("hello%20world")).unwrap(), "hello world");+        assert_eq!(String::from_utf8(percent_decode("%E5%B9%B4%E5%BA%A6")).unwrap(), "年度");+        assert_eq!(percent_decode("invalid%2G%"), b"invalid%2G%");++        #[cfg(not(windows))]+        {+            let unix_path = std::path::Path::new("file:///tmp/my%20file.pdf\r\n");+            assert_eq!(clean_dropped_path(unix_path), Some(std::path::PathBuf::from("/tmp/my file.pdf")));++            let localhost = std::path::Path::new("file://localhost/tmp/%E5%B9%B4%E5%BA%A6.pdf");+            assert_eq!(clean_dropped_path(localhost), Some(std::path::PathBuf::from("/tmp/年度.pdf")));+        }++        #[cfg(windows)]+        {+            let win_path = std::path::Path::new("file:///C:/Users/User/Desktop/my%20file.pdf");+            assert_eq!(clean_dropped_path(win_path), Some(std::path::PathBuf::from("C:/Users/User/Desktop/my file.pdf")));++            let localhost_win = std::path::Path::new("file://localhost/D:/docs/report.pdf");+            assert_eq!(clean_dropped_path(localhost_win), Some(std::path::PathBuf::from("D:/docs/report.pdf")));+        }++        assert_eq!(clean_dropped_path(std::path::Path::new("")), None);+        assert_eq!(clean_dropped_path(std::path::Path::new("relative.pdf")), None);+    }+     fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {         let mut out = Vec::new();         for entry in std::fs::read_dir(dir).unwrap() {diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs--- a/crates/ui-egui/tests/it/combine_grid.rs+++ b/crates/ui-egui/tests/it/combine_grid.rs@@ -1909,3 +1909,101 @@ fn open_documents_already_in_the_list_are_not_added_again() {     add_all(&mut h);     assert_eq!(names(&h), ["a.pdf", "report.pdf", "report.pdf", "b.pdf"]); }++#[derive(Debug)]+struct DroppedUri {+    path: std::path::PathBuf,+}++impl egui::DroppedFile for DroppedUri {+    fn path(&self) -> &std::path::Path {+        &self.path+    }+    fn bytes(&self) -> Result<Vec<u8>, String> {+        Err("simulated desktop drop without preloaded bytes".into())+    }+}++/// #4985: Dragging and dropping file:// URIs and URL-encoded file paths into Combine files works+/// and adds the dropped files in batch.+#[test]+fn dropping_file_uris_and_url_encoded_paths_into_combine_grid_adds_them() {+    let dir = std::env::temp_dir().join(format!("pdfcraft-combine-uri-drop-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).unwrap();++    let path1 = dir.join("first doc.pdf");+    let path2 = dir.join("second_doc.pdf");+    std::fs::write(&path1, fixture(1)).unwrap();+    std::fs::write(&path2, fixture(2)).unwrap();++    let norm1 = path1.to_string_lossy().replace('\\', "/");+    let norm1 = norm1.trim_start_matches('/');+    let uri1_str = format!("file:///{norm1}").replace(' ', "%20");++    let norm2 = path2.to_string_lossy().replace('\\', "/");+    let norm2 = norm2.trim_start_matches('/');+    let uri2_str = format!("file://localhost/{norm2}");++    let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {+        let mut app = PdfCraftApp::new();+        app.set_option("language", "en").unwrap();+        app+    });+    h.run_steps(3);+    h.state_mut().execute("page.combine");+    h.run_steps(2);++    let drop1: egui::DroppedFileHandle = std::sync::Arc::new(DroppedUri { path: uri1_str.into() });+    let drop2: egui::DroppedFileHandle = std::sync::Arc::new(DroppedUri { path: uri2_str.into() });++    h.input_mut().dropped_files.extend([drop1, drop2]);+    h.run_steps(4);++    let _ = std::fs::remove_dir_all(&dir);++    assert_eq!(names(&h), ["first doc.pdf", "second_doc.pdf"], "dropped file URIs are added to combine draft in batch");+}++/// #4985: Dropping mixed files and folders preserves drop order, and unreadable paths do not prevent readable files from being staged.+#[test]+fn dropping_mixed_files_and_folders_into_combine_preserves_order() {+    let dir = std::env::temp_dir().join(format!("pdfcraft-combine-mixed-drop-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    let sub = dir.join("subfolder");+    std::fs::create_dir_all(&sub).unwrap();++    let file_before = dir.join("1_before.pdf");+    let folder_file = sub.join("2_inside.pdf");+    let file_after = dir.join("3_after.pdf");+    let unreadable = dir.join("nonexistent.pdf");++    std::fs::write(&file_before, fixture(1)).unwrap();+    std::fs::write(&folder_file, fixture(1)).unwrap();+    std::fs::write(&file_after, fixture(1)).unwrap();++    let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {+        let mut app = PdfCraftApp::new();+        app.set_option("language", "en").unwrap();+        app+    });+    h.run_steps(3);+    h.state_mut().execute("page.combine");+    h.run_steps(2);++    let drop_before: egui::DroppedFileHandle = std::sync::Arc::new(DroppedUri { path: file_before });+    let drop_folder: egui::DroppedFileHandle = std::sync::Arc::new(DroppedUri { path: sub });+    let drop_bad: egui::DroppedFileHandle = std::sync::Arc::new(DroppedUri { path: unreadable });+    let drop_after: egui::DroppedFileHandle = std::sync::Arc::new(DroppedUri { path: file_after });++    h.input_mut().dropped_files.extend([drop_before, drop_folder, drop_bad, drop_after]);+    h.run_steps(4);++    let _ = std::fs::remove_dir_all(&dir);++    assert_eq!(+        names(&h),+        ["1_before.pdf", "2_inside.pdf", "3_after.pdf"],+        "mixed drop preserves order and unreadable path does not block readable files"+    );+}