From f050fd5f16ff7c9b093a1312714c02ceb72410ca Mon Sep 17 00:00:00 2001 From: Javad Effatdoost Date: Mon, 5 Oct 2026 10:08:46 +0330 Subject: [PATCH 1/5] 19 cross-platform: full-workspace mac/win CI with PDFium, reveal-in-folder toast action, platform-matched vendor dir --- .github/workflows/ci.yml | 26 ++++++++---- README.md | 30 ++++++++++--- crates/app/src/app.rs | 68 ++++++++++++++++++++++++++++-- crates/app/src/ui_tests.rs | 13 ++++++ crates/mark-pdf/src/bind.rs | 49 ++++++++++++++++++--- crates/platform/examples/reveal.rs | 29 +++++++++++++ crates/platform/src/lib.rs | 38 +++++++++++++++++ script/fetch-pdfium.sh | 2 +- 8 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 crates/platform/examples/reveal.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f464f5..065684f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ jobs: - uses: Swatinem/rust-cache@v2 - # Real PDFium runtime: mark-pdf integration tests bind to it; tests - # skip gracefully where it is absent (macos/windows domain jobs). + # Real PDFium runtime: mark-pdf integration tests bind to it on + # every platform (tests still skip gracefully where it is absent). - name: Fetch PDFium runtime run: ./script/fetch-pdfium.sh @@ -39,8 +39,12 @@ jobs: - run: cargo check --workspace --all-targets - run: cargo test --workspace --all-targets - domain: - name: ${{ matrix.os }} (domain crates check + test) + # Phase 11 (plan.md §19.2, §20.5): the full workspace — including the + # UI and platform crates — compiles and tests on every platform, with + # the real PDFium runtime fetched per OS so mark-pdf integration tests + # bind it everywhere instead of skipping. + cross-platform: + name: ${{ matrix.os }} (full workspace check + test, PDFium bound) strategy: fail-fast: false matrix: @@ -53,8 +57,12 @@ jobs: - uses: Swatinem/rust-cache@v2 - # Domain crates must build everywhere; UI/platform crates may lag - # (plan.md §20.5). PDFium binding is runtime-only, so mark-pdf - # compiles without the binary; unit tests never load it. - - run: cargo check --workspace --exclude mark-app --exclude platform --all-targets - - run: cargo test --workspace --exclude mark-app --exclude platform --all-targets + # bash + curl + tar exist on all three runner images (Git bash on + # Windows); the script detects the platform and fetches the matching + # runtime (dylib on macOS, dll on Windows). + - name: Fetch PDFium runtime + shell: bash + run: ./script/fetch-pdfium.sh + + - run: cargo check --workspace --all-targets + - run: cargo test --workspace --all-targets diff --git a/README.md b/README.md index e63af51..2a0c8d1 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Native Rust, no Electron, no network, fully local. ## Status -Pre-alpha — Phase 0 (bootstrap). See `plan.md` for the full specification -and `documentation.md` for the development log. +Pre-alpha — under active development. See `plan.md` for the full +specification and `documentation.md` for the development log. ## Stack @@ -19,17 +19,35 @@ and `documentation.md` for the development log. | ---------- | --------------------------------------------- | | UI | [GPUI](https://github.com/zed-industries/zed) via [gpui-kit](https://github.com/longbridge/gpui-kit) + [gpui-omarchy](https://github.com/huacnlee/gpui-omarchy) | | PDF engine | PDFium via [pdfium-render](https://github.com/ajrcarey/pdfium-render) | -| Platforms | Linux (Omarchy/Wayland) first, macOS and Windows next | +| Platforms | Linux (Omarchy/Wayland) first; macOS and Windows compile+CI-verified | ## Development setup (Linux / Omarchy) -Prerequisites: Rust (rustup), `clang`, `pkg-config`, `libxkbcommon`, -Wayland/EGL development packages. On Arch: +Prerequisites: Rust (rustup), `clang`, `pkg-config`, and the GPUI Linux +backend libraries — `libxkbcommon` (+ X11 variant), `libxcb1`, Wayland +(client + EGL), EGL/GLESv2, and `fontconfig` development packages. + +On Arch / Omarchy: ```bash -sudo pacman -S rust clang pkg-config libxkbcommon wayland-protocols mesa +sudo pacman -S rust clang pkg-config libxkbcommon wayland-protocols mesa fontconfig ``` +On Debian / Ubuntu (the exact set CI installs): + +```bash +sudo apt install clang pkg-config libxkbcommon-dev libxkbcommon-x11-dev \ + libxcb1-dev libwayland-dev libegl1-mesa-dev libgles2-mesa-dev \ + libfontconfig1-dev +``` + +## Development setup (macOS / Windows) + +Rust (rustup) and — on Windows — the [Git bash]( +https://gitforwindows.org/) tools (Git for Windows includes them; used by +`script/fetch-pdfium.sh`). No extra system libraries: GPUI links the +platform frameworks (Metal / D3D via wgpu) out of the box. + Fetch the PDFium runtime (one-time, per platform): ```bash diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 30a60d6..49ad330 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -91,11 +91,21 @@ pub(crate) enum PendingAction { } /// One transient toast notice (plan.md §10, §17): asynchronous status that -/// requires no decision. +/// requires no decision. An optional action renders as an inline button +/// (the export toast's "Show in folder"). #[derive(Clone, Debug)] struct ToastNotice { message: SharedString, kind: ToastKind, + action: Option, +} + +/// An inline affordance on a toast. +#[derive(Clone, Debug)] +struct ToastAction { + label: SharedString, + /// Reveal this path in the platform file manager. + reveal: PathBuf, } #[derive(Clone, Copy, Debug)] @@ -1489,7 +1499,15 @@ impl MarkApp { } let name: SharedString = file_name(&destination).into(); tracing::info!(destination = %name, "Export completed"); - self.push_toast(format!("Exported {name}"), ToastKind::Success, cx); + self.push_toast_with_action( + format!("Exported {name}"), + ToastKind::Success, + Some(ToastAction { + label: "Show in folder".into(), + reveal: destination.clone(), + }), + cx, + ); self.export = Some(ExportStatus::Done { name }); if let Some(pending) = self.resume_after_export.take() { self.perform_pending(pending, cx); @@ -1517,6 +1535,17 @@ impl MarkApp { message: impl Into, kind: ToastKind, cx: &mut Context, + ) { + self.push_toast_with_action(message, kind, None, cx); + } + + /// [`Self::push_toast`] with an inline action button. + fn push_toast_with_action( + &mut self, + message: impl Into, + kind: ToastKind, + action: Option, + cx: &mut Context, ) { let id = self.next_toast_id; self.next_toast_id += 1; @@ -1525,6 +1554,7 @@ impl MarkApp { ToastNotice { message: message.into(), kind, + action, }, ToastOptions { timeout: Some(TOAST_TIMEOUT), @@ -1996,11 +2026,43 @@ fn toast_area( .size(rems(1.)) .text_color(notice.kind.color(theme)), ) - .child(div().child(notice.message.clone())), + .child(div().child(notice.message.clone())) + .children(notice.action.as_ref().map(|action| { + let reveal = action.reveal.clone(); + div() + .id(format!("mark-toast-action-{id}")) + .test_support() + .ml_auto() + .text_size(rems(0.75)) + .text_color(theme.accent) + .rounded_sm() + .px(rems(0.25)) + .hover(|style| style.underline().bg(theme.background)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |_, _: &MouseDownEvent, _, cx| { + reveal_path(reveal.clone(), cx); + }), + ) + .child(action.label.clone()) + })), ) })) } +/// Reveals a path in the platform file manager; failures log and vanish +/// (a missing helper is never worth interrupting the user for). +fn reveal_path(path: PathBuf, cx: &mut Context) { + tracing::info!(path = %path.display(), "revealing in file manager"); + cx.background_executor() + .spawn(async move { + if let Err(error) = platform::reveal_in_file_manager(&path) { + tracing::warn!(%error, "could not reveal in file manager"); + } + }) + .detach(); +} + /// The document workspace: thumbnail sidebar, canvas stage with placed /// objects, and the assets panel (plan.md §10). fn workspace( diff --git a/crates/app/src/ui_tests.rs b/crates/app/src/ui_tests.rs index c1f1995..6bec67b 100644 --- a/crates/app/src/ui_tests.rs +++ b/crates/app/src/ui_tests.rs @@ -1063,4 +1063,17 @@ fn duplicate_to_page_and_export_announce_through_toasts(cx: &mut TestAppContext) .any(|m| m.starts_with("Exported ") && m.ends_with("page-signed.png")), "export toast: {messages:?}" ); + + // The export toast carries the reveal affordance (Phase 9's "open + // folder" Next point). The button is asserted, not clicked: the + // handler is a fire-and-forget platform spawn (opening a real file + // manager from `cargo test` would be worse than no click — the same + // reason the portal dialogs are never invoked headlessly). + with_window(&fx, cx, |window, cx| { + window.render_frame(cx); + assert!( + window.try_find("mark-toast-action-1").is_some(), + "the export toast has a Show in folder action" + ); + }); } diff --git a/crates/mark-pdf/src/bind.rs b/crates/mark-pdf/src/bind.rs index 90d393b..1028d73 100644 --- a/crates/mark-pdf/src/bind.rs +++ b/crates/mark-pdf/src/bind.rs @@ -14,6 +14,32 @@ use std::sync::OnceLock; use pdfium_render::prelude::Pdfium; +/// The `vendor/pdfium/` directory name the fetch script uses +/// for the *current* platform, so a machine with several runtimes fetched +/// (cross-platform dev checkout) binds the right one. +fn vendor_platform_dir() -> String { + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "unknown" + }; + if cfg!(target_os = "linux") { + if cfg!(target_env = "musl") { + format!("linux-musl-{arch}") + } else { + format!("linux-{arch}") + } + } else if cfg!(target_os = "macos") { + format!("mac-{arch}") + } else if cfg!(target_os = "windows") { + format!("win-{arch}") + } else { + format!("unknown-{arch}") + } +} + /// First `vendor/pdfium//lib` directory created by /// `script/fetch-pdfium.sh`. /// @@ -26,13 +52,22 @@ pub fn vendor_lib_dir() -> Option { PathBuf::from("vendor/pdfium"), PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../vendor/pdfium"), ]; - let platform_lib = |dir: &PathBuf| { - std::fs::read_dir(dir).ok()?.flatten().find_map(|platform| { - let lib = platform.path().join("lib"); - lib.is_dir().then_some(lib) - }) - }; - candidates.iter().find_map(platform_lib) + // The current platform's runtime first; any fetched runtime as a + // fallback (a wrong-arch library simply fails to load, not silently + // skip — better to try the exact match before anything else). + let platform_dir = vendor_platform_dir(); + candidates.iter().find_map(|dir| { + dir.join(&platform_dir) + .join("lib") + .is_dir() + .then(|| dir.join(&platform_dir).join("lib")) + .or_else(|| { + std::fs::read_dir(dir).ok()?.flatten().find_map(|platform| { + let lib = platform.path().join("lib"); + lib.is_dir().then_some(lib) + }) + }) + }) } /// The process-wide PDFium instance: the first caller loads the vendored diff --git a/crates/platform/examples/reveal.rs b/crates/platform/examples/reveal.rs new file mode 100644 index 0000000..695215f --- /dev/null +++ b/crates/platform/examples/reveal.rs @@ -0,0 +1,29 @@ +//! Manual verification for `reveal_in_file_manager` (the Phase 11 +//! cross-platform seam). +//! +//! ```bash +//! cargo run -p platform --example reveal -- +//! ``` +//! +//! Opens the platform file manager focused on ``: Finder with the +//! file selected on macOS, Explorer with it selected on Windows, the +//! containing folder on Linux. + +use std::path::PathBuf; + +fn main() { + let path = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| { + eprintln!("usage: reveal "); + std::process::exit(2); + }); + match platform::reveal_in_file_manager(&path) { + Ok(()) => println!("reveal requested for {}", path.display()), + Err(error) => { + eprintln!("reveal failed: {error}"); + std::process::exit(1); + } + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 3c17945..15ace59 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -36,6 +36,44 @@ pub fn app_config_dir() -> Option { } } +/// Reveals a file in the platform file manager: macOS selects it in Finder +/// (`open -R`), Windows opens the folder with it selected +/// (`explorer /select,`), Linux opens the containing folder (`xdg-open`). +/// +/// Fire-and-forget: the process is spawned detached and the handle dropped. +/// Errors (missing helper, headless session) are the caller's to log. +pub fn reveal_in_file_manager(path: &Path) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + let mut command = { + let mut command = std::process::Command::new("open"); + command.arg("-R").arg(path); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = std::process::Command::new("explorer"); + command.arg(format!("/select,{}", path.display())); + command + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let parent = path.parent().unwrap_or(path); + let mut command = std::process::Command::new("xdg-open"); + command.arg(parent); + command + }; + #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))] + let mut command = std::process::Command::new("true"); + // The helper never gets our pipes: a file manager holding stdout open + // would keep parents (shells, test harnesses) waiting on EOF. + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) +} + /// Source of native open/save dialogs (plan.md §9.1). pub trait FilePicker { /// Asks the user to pick one existing document. diff --git a/script/fetch-pdfium.sh b/script/fetch-pdfium.sh index d2e9f74..0488b6f 100755 --- a/script/fetch-pdfium.sh +++ b/script/fetch-pdfium.sh @@ -29,7 +29,7 @@ detect_platform() { Linux) # musl-based distros need the musl build if ldd --version 2>&1 | grep -qi musl; then - echo "linux-musl-${arch#x64}" + echo "linux-musl-${arch}" else echo "linux-${arch}" fi From 2ccf685253b6aa8013dba72dac0d8c2bd9f92c7c Mon Sep 17 00:00:00 2001 From: Javad Effatdoost Date: Mon, 5 Oct 2026 10:26:04 +0330 Subject: [PATCH 2/5] ci-fix: platform-aware test modifier, canonicalized recents (verbatim/symlink), PDF-open pump for foreign-thread wake --- crates/app/src/app.rs | 2 +- crates/app/src/ui_tests.rs | 70 ++++++++++++++++++++++++++------------ crates/platform/src/lib.rs | 29 ++++++++++++++++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 49ad330..2257cae 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -500,7 +500,7 @@ impl MarkApp { /// Paths are canonicalized so a relative CLI argument still opens from /// whatever directory the next launch uses. fn record_recent(&mut self, path: &Path, cx: &mut Context) { - let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let canonical = platform::canonicalize(path); self.recent.record(&canonical); let Some(file) = self.recent_path.clone() else { return; diff --git a/crates/app/src/ui_tests.rs b/crates/app/src/ui_tests.rs index 6bec67b..e284c36 100644 --- a/crates/app/src/ui_tests.rs +++ b/crates/app/src/ui_tests.rs @@ -208,6 +208,32 @@ fn with_window( .expect("update test window") } +/// The primary modifier tests press: the same choice `init_keybindings` +/// makes, so synthetic keys exercise the bindings a real user hits +/// (Cmd on macOS, Ctrl elsewhere — plan.md §15, §19.2). +fn modifier() -> &'static str { + if cfg!(target_os = "macos") { + "cmd" + } else { + "ctrl" + } +} + +/// Pumps in small steps until a document is open — `run_until_parked` +/// alone cannot await the PDF worker's foreign-thread wake (the Phase 9 +/// scheduler lesson; without this, the frame can still show "Opening"). +fn wait_until_opened(fx: &Fixture, cx: &mut TestAppContext) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + cx.run_until_parked(); + let opened = fx.app.update(cx, |app, _| app.opened_document().is_some()); + if opened || std::time::Instant::now() >= deadline { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + // ----- tests ------------------------------------------------------------------ #[gpui_kit::test] @@ -286,14 +312,14 @@ fn drag_moves_object_and_undo_redo_round_trip(cx: &mut TestAppContext) { // Ctrl+Z: back to the placement position exactly. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); }); let (_, undone) = sole_object(&fx, cx); assert_eq!(undone, before); // Ctrl+Shift+Z: the move returns. with_window(&fx, cx, |window, cx| { - window.press("ctrl-shift-z", cx); + window.press(&format!("{}-shift-z", modifier()), cx); }); let (_, redone) = sole_object(&fx, cx); assert_eq!(redone, after); @@ -336,7 +362,7 @@ fn handle_drag_resizes_aspect_locked_and_undo_restores(cx: &mut TestAppContext) // Ctrl+Z restores the exact prior geometry. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); }); let (_, undone) = sole_object(&fx, cx); assert_eq!(undone, before); @@ -361,7 +387,7 @@ fn delete_removes_object_and_undo_restores_it(cx: &mut TestAppContext) { // Ctrl+Z brings it back with its geometry. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); }); let (restored_id, restored) = sole_object(&fx, cx); assert_eq!(restored_id, id); @@ -369,7 +395,7 @@ fn delete_removes_object_and_undo_restores_it(cx: &mut TestAppContext) { // Redo deletes again. with_window(&fx, cx, |window, cx| { - window.press("ctrl-shift-z", cx); + window.press(&format!("{}-shift-z", modifier()), cx); }); assert_eq!(object_count(&fx, cx), 0); } @@ -382,7 +408,7 @@ fn ctrl_d_duplicates_offset_and_selects_the_copy(cx: &mut TestAppContext) { let (id, before) = place_signature(&fx, cx); with_window(&fx, cx, |window, cx| { - window.press("ctrl-d", cx); + window.press(&format!("{}-d", modifier()), cx); }); let objects = page_objects(&fx, cx, 0); @@ -401,7 +427,7 @@ fn ctrl_d_duplicates_offset_and_selects_the_copy(cx: &mut TestAppContext) { // Undo removes the copy only. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); }); let objects = page_objects(&fx, cx, 0); assert_eq!(objects.len(), 1); @@ -415,13 +441,13 @@ fn copy_paste_cascades_offset_and_undo_removes_pastes(cx: &mut TestAppContext) { // Copy alone changes nothing. with_window(&fx, cx, |window, cx| { - window.press("ctrl-c", cx); + window.press(&format!("{}-c", modifier()), cx); }); assert_eq!(object_count(&fx, cx), 1); // First paste: one step offset from the copied position, selected. with_window(&fx, cx, |window, cx| { - window.press("ctrl-v", cx); + window.press(&format!("{}-v", modifier()), cx); }); let objects = page_objects(&fx, cx, 0); assert_eq!(objects.len(), 2); @@ -433,7 +459,7 @@ fn copy_paste_cascades_offset_and_undo_removes_pastes(cx: &mut TestAppContext) { // Second paste cascades one step further (plan.md §13.2: never exactly // on top). with_window(&fx, cx, |window, cx| { - window.press("ctrl-v", cx); + window.press(&format!("{}-v", modifier()), cx); }); let objects = page_objects(&fx, cx, 0); assert_eq!(objects.len(), 3); @@ -442,8 +468,8 @@ fn copy_paste_cascades_offset_and_undo_removes_pastes(cx: &mut TestAppContext) { // Undo twice: back to just the placed original. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); + window.press(&format!("{}-z", modifier()), cx); }); let objects = page_objects(&fx, cx, 0); assert_eq!(objects.len(), 1); @@ -486,8 +512,8 @@ fn toolbar_size_steps_commit_one_resize_each(cx: &mut TestAppContext) { // Undo both steps back to the placed geometry. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); + window.press(&format!("{}-z", modifier()), cx); }); let (_, undone) = sole_object(&fx, cx); assert_eq!(undone, before); @@ -590,7 +616,7 @@ fn duplicate_to_page_menu_copies_to_the_chosen_page(cx: &mut TestAppContext) { // Undo removes only the copy. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); }); assert_eq!(page_objects(&fx, cx, 0).len(), 1); assert_eq!(page_objects(&fx, cx, 1).len(), 0); @@ -716,7 +742,7 @@ fn export_pdf_document_places_real_image_objects(cx: &mut TestAppContext) { let fx = seeded(cx); let path = pdf_fixture("letter-portrait.pdf"); fx.app.update(cx, |app, cx| app.open_path(path, cx)); - cx.run_until_parked(); + wait_until_opened(&fx, cx); // Sign page 1 with the stamp through the real placement path. with_window(&fx, cx, |window, cx| { @@ -846,11 +872,13 @@ fn recent_documents_persist_list_and_reopen(cx: &mut TestAppContext) { let fx = fixture(cx); // opens page.png through the production path cx.run_until_parked(); - // The list persisted (plan.md §18), most recent first. + // The list persisted (plan.md §18), most recent first — canonicalized, + // so symlinked tempdirs (macOS `/private/var`) and 8.3 short names + // (Windows `RUNNER~1`) normalize before comparison. let recent = mark_export::recent::RecentDocuments::open(&fx._dir.path().join("library/recent.json")) .expect("recent file readable"); - assert_eq!(recent.paths(), [fx.page_path.as_path()]); + assert_eq!(recent.paths(), [platform::canonicalize(&fx.page_path)]); // A fresh app on the same storage lists it in the empty workspace; // clicking the row opens the document. @@ -908,7 +936,7 @@ fn window_title_tracks_dirty_state(cx: &mut TestAppContext) { // Undo back to the save point clears it. with_window(&fx, cx, |window, cx| { - window.press("ctrl-z", cx); + window.press(&format!("{}-z", modifier()), cx); }); with_window(&fx, cx, |window, cx| { window.render_frame(cx); @@ -983,7 +1011,7 @@ fn quit_prompts_when_dirty_and_escape_cancels(cx: &mut TestAppContext) { // Ctrl+Q on a dirty document opens the dialog instead of quitting. with_window(&fx, cx, |window, cx| { window.render_frame(cx); - window.press("ctrl-q", cx); + window.press(&format!("{}-q", modifier()), cx); }); assert!(fx.app.update(cx, |app, _| app.confirm_pending().is_some())); @@ -1002,7 +1030,7 @@ fn quit_prompts_when_dirty_and_escape_cancels(cx: &mut TestAppContext) { // (a no-op in the test platform, but the dialog must resolve). with_window(&fx, cx, |window, cx| { window.render_frame(cx); - window.press("ctrl-q", cx); + window.press(&format!("{}-q", modifier()), cx); }); with_window(&fx, cx, |window, cx| { window.render_frame(cx); diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 15ace59..8c1ea06 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -36,6 +36,35 @@ pub fn app_config_dir() -> Option { } } +/// `std::fs::canonicalize` with the Windows verbatim prefix stripped. +/// +/// Canonical paths are what gets persisted (the recents list): resolved +/// symlinks survive `cwd` changes and 8.3 short names (`RUNNER~1`) become +/// real ones. But on Windows `canonicalize` returns `\\?\`-prefixed +/// verbatim paths — ugly in UI text and surprising to other tools — so +/// the prefix comes off (`\\?\C:\…` → `C:\…`, `\\?\UNC\server\…` → +/// `\\server\…`). A missing file returns the path as given. +pub fn canonicalize(path: &Path) -> PathBuf { + match std::fs::canonicalize(path) { + Ok(canonical) => strip_windows_verbatim(canonical), + Err(_) => path.to_path_buf(), + } +} + +/// Removes the `\\?\` verbatim prefix from an absolute Windows path. +fn strip_windows_verbatim(path: PathBuf) -> PathBuf { + let Some(text) = path.to_str() else { + return path; // non-UTF-16-ish components: keep the verbatim form + }; + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{rest}")) + } else if let Some(rest) = text.strip_prefix(r"\\?\") { + PathBuf::from(rest) + } else { + path + } +} + /// Reveals a file in the platform file manager: macOS selects it in Finder /// (`open -R`), Windows opens the folder with it selected /// (`explorer /select,`), Linux opens the containing folder (`xdg-open`). From ce421ab0616250ca8fc86c1cc942a8bd20613bf6 Mon Sep 17 00:00:00 2001 From: Javad Effatdoost Date: Mon, 5 Oct 2026 10:41:45 +0330 Subject: [PATCH 3/5] ci-fix: reopen assert compares the canonicalized recorded path --- crates/app/src/ui_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/app/src/ui_tests.rs b/crates/app/src/ui_tests.rs index e284c36..f60ca8e 100644 --- a/crates/app/src/ui_tests.rs +++ b/crates/app/src/ui_tests.rs @@ -897,7 +897,8 @@ fn recent_documents_persist_list_and_reopen(cx: &mut TestAppContext) { .path() .to_path_buf() }); - assert_eq!(source, fx.page_path); + // The reopened document's source is the recorded (canonicalized) path. + assert_eq!(source, platform::canonicalize(&fx.page_path)); // Gone from disk → gone from the list (convenience data only). let missing = second._dir.path().join("gone.pdf"); From 556b567897a62bf22980c7fe116a12b994e9807b Mon Sep 17 00:00:00 2001 From: Javad Effatdoost Date: Mon, 5 Oct 2026 10:57:41 +0330 Subject: [PATCH 4/5] =?UTF-8?q?ci-fix:=20windows=20pdfium.dll=20lives=20un?= =?UTF-8?q?der=20bin/=20=E2=80=94=20search=20lib+bin;=20add=20check=5Fbind?= =?UTF-8?q?=20proof=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 ++++++++ crates/mark-pdf/src/bind.rs | 41 +++++++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 065684f..f2e10c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,5 +64,13 @@ jobs: shell: bash run: ./script/fetch-pdfium.sh + # Proof the runtime actually bound: a green test suite alone can't + # show it (tests skip gracefully without a runtime — that path is + # exactly what this step rules out on Windows, whose archive keeps + # pdfium.dll under bin/, not lib/). + - name: Verify PDFium binding + shell: bash + run: cargo run -p mark-pdf --example check_bind + - run: cargo check --workspace --all-targets - run: cargo test --workspace --all-targets diff --git a/crates/mark-pdf/src/bind.rs b/crates/mark-pdf/src/bind.rs index 1028d73..4139d61 100644 --- a/crates/mark-pdf/src/bind.rs +++ b/crates/mark-pdf/src/bind.rs @@ -40,7 +40,12 @@ fn vendor_platform_dir() -> String { } } -/// First `vendor/pdfium//lib` directory created by +/// Subdirectories a fetched runtime's library may live in: bblanchon's +/// archives use `lib/` on Linux/macOS but `bin/` for `pdfium.dll` on +/// Windows. +const VENDOR_LIB_SUBDIRS: [&str; 2] = ["lib", "bin"]; + +/// First `vendor/pdfium/` library directory created by /// `script/fetch-pdfium.sh`. /// /// Resolved from the working directory (app and examples run from the @@ -52,22 +57,28 @@ pub fn vendor_lib_dir() -> Option { PathBuf::from("vendor/pdfium"), PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../vendor/pdfium"), ]; - // The current platform's runtime first; any fetched runtime as a - // fallback (a wrong-arch library simply fails to load, not silently - // skip — better to try the exact match before anything else). let platform_dir = vendor_platform_dir(); - candidates.iter().find_map(|dir| { - dir.join(&platform_dir) - .join("lib") - .is_dir() - .then(|| dir.join(&platform_dir).join("lib")) - .or_else(|| { - std::fs::read_dir(dir).ok()?.flatten().find_map(|platform| { - let lib = platform.path().join("lib"); - lib.is_dir().then_some(lib) - }) + let dir_with_library = |dir: &PathBuf| -> Option { + // The current platform's runtime first; any fetched runtime as a + // fallback (a wrong-arch library simply fails to load, not + // silently skip — better to try the exact match before anything + // else). + let names = std::iter::once(platform_dir.clone()).chain( + std::fs::read_dir(dir) + .ok()? + .flatten() + .filter_map(|platform| platform.file_name().into_string().ok()), + ); + names + .filter_map(|name| { + VENDOR_LIB_SUBDIRS + .iter() + .map(|sub| dir.join(&name).join(sub)) + .find(|lib| lib.is_dir()) }) - }) + .find(|lib| lib.is_dir()) + }; + candidates.iter().find_map(dir_with_library) } /// The process-wide PDFium instance: the first caller loads the vendored From 18d711d29377be66c00e023b1f588f02684b6af7 Mon Sep 17 00:00:00 2001 From: Javad Effatdoost Date: Mon, 5 Oct 2026 11:04:17 +0330 Subject: [PATCH 5/5] =?UTF-8?q?ci-fix:=20vendor=20dir=20must=20contain=20t?= =?UTF-8?q?he=20library=20file=20=E2=80=94=20windows=20lib/=20holds=20only?= =?UTF-8?q?=20the=20.lib=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/mark-pdf/src/bind.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/mark-pdf/src/bind.rs b/crates/mark-pdf/src/bind.rs index 4139d61..7cd3ea1 100644 --- a/crates/mark-pdf/src/bind.rs +++ b/crates/mark-pdf/src/bind.rs @@ -41,10 +41,21 @@ fn vendor_platform_dir() -> String { } /// Subdirectories a fetched runtime's library may live in: bblanchon's -/// archives use `lib/` on Linux/macOS but `bin/` for `pdfium.dll` on -/// Windows. +/// archives use `lib/` on Linux/macOS but keep `pdfium.dll` in `bin/` on +/// Windows (`lib/` there holds only the import library `.lib`). const VENDOR_LIB_SUBDIRS: [&str; 2] = ["lib", "bin"]; +/// The library file name pdfium-render loads on this platform. +fn platform_library_file() -> &'static str { + if cfg!(target_os = "windows") { + "pdfium.dll" + } else if cfg!(target_os = "macos") { + "libpdfium.dylib" + } else { + "libpdfium.so" + } +} + /// First `vendor/pdfium/` library directory created by /// `script/fetch-pdfium.sh`. /// @@ -57,13 +68,13 @@ pub fn vendor_lib_dir() -> Option { PathBuf::from("vendor/pdfium"), PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../vendor/pdfium"), ]; - let platform_dir = vendor_platform_dir(); + let library = platform_library_file(); let dir_with_library = |dir: &PathBuf| -> Option { // The current platform's runtime first; any fetched runtime as a // fallback (a wrong-arch library simply fails to load, not // silently skip — better to try the exact match before anything - // else). - let names = std::iter::once(platform_dir.clone()).chain( + // else). A directory only counts when the library *file* is in it. + let names = std::iter::once(vendor_platform_dir()).chain( std::fs::read_dir(dir) .ok()? .flatten() @@ -74,9 +85,9 @@ pub fn vendor_lib_dir() -> Option { VENDOR_LIB_SUBDIRS .iter() .map(|sub| dir.join(&name).join(sub)) - .find(|lib| lib.is_dir()) + .find(|lib| lib.join(library).is_file()) }) - .find(|lib| lib.is_dir()) + .next() }; candidates.iter().find_map(dir_with_library) }