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"
+    );
+}
