Issue #431: reload a document when its file changes on disk (#459)

* Engine: reload a document from new bytes, keeping its id

* Issue #431: reload a document when its file changes on disk

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
pauliusiv123-rgbandBrandon Thomas authored and GitHub committed 2026-10-11 14:31:32 -04:00
1 parent e4c9cc1a19
commit 57c9e3a9b4
27 files changed
+814 -16

No files matched your search

+16 -16
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a562bce9a9ba1be6bc464ef74104bbf53f88291a5ec522d18ba3f607e8490cf6"
sha256 = "7936028709844d28d14489d608810480a1af5a707e883032161bc2fe95ff5d67"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "3d9dde3978c43dea4ad461288c859d466520888aa4fce0ea43514f8206bc084f"
sha256 = "72d037149b24ad84a83cc7f0a7d27b1ecae93451d4cdc19ed861899789abbb74"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a84c17c7a8f297bef07a613e63116f9a8bb05aa02fd8442a684648df3a768224"
sha256 = "bda5289ace1c4c959f0b4fc2482cb8ae3b4198733994b05042a584ece827dd3a"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "59fbeb2f5c912f7b77c1f117e0f68b8806bb015e0d187721bc77b6708cb3e088"
sha256 = "781ac07860770313bc2a74cb1e7b312a5e027f3299d137c84d799829345f1eee"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "47a294326663323e42e92b4286d68b42741d0f6c8bb5df2023ed6c5697595332"
sha256 = "12c812d37a92633a81f4dab6da7808cb6c55eb7bb6fa318f0c79a58ede065bce"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "15aada11b884a55869b2080f79a6902134a4cdeea1c2c011484b22b5c5f53747"
sha256 = "cec6518ba35fdd26669c200283b3a46e908f2ba940f6917b414bf1f68bcb19ce"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6945b0d3b6b6bcd8cad936b8b440484903fa71ce439a5da6fddf595ffa9f0233"
sha256 = "386d5e5e5dcdf80487d304f914a0ac0f7e17c5d2134ff5df37b477b8cb5bf3ca"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8ee5d4a658dd29e41e171103343eb223cc360956fb533788cec8676dcdf1c6ec"
sha256 = "0c52c43e61ebc4d9486ee699fb442442ed4813a0f9cf365a48e2029cfe7cb950"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "884adac1fca5f8a4bb14d947e9feabdee2ca18e86c3a1dbc2e7a042512c37ec2"
sha256 = "771b72fbed8f7ef9a5689fd84322b2feb27e36295f7f0228f755fee678c34774"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5e794e7ab841956a509ecdf1701d3e61ced122d10e962d20ce14068cdd8d4df4"
sha256 = "4be664cc867d6b2a8cb74a87a5163530249de36a46b39115ff071f2f4402b1cb"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f83db805a29b5ac05bea6c89b7542d7afdb8fab8edf578a8d976aca7e18cd348"
sha256 = "3b37271fc6aed595b01ab42fd39cefe3dfd0e5e0e25905620590d9be5bc29c02"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ad66bf99115990740d7a65ced3495d3dc954cd7c229ac35e3f136c20c439420b"
sha256 = "8e6549bb81c37757822776e3f8f46b3479688bd950508c9c311a3723a1a7b173"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f0f5451f8602e2d9ac27386e3fabde563bcc69d357c235db7babfeee8cac6cb6"
sha256 = "8d8ec98406f3d8b44766c3daad04af5beac39a5e69cceedfb1b881bd20457023"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ca0f9ea68fa5df68c468dd535baf85aa11e5e7bcd704149f7d2df44c284a0cff"
sha256 = "b995a67d0fdac2b2d567e2f37d2688a31e5a6222ffca59a95c304c7f331e18ad"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d5eb9db7c07041b0dca02dbe5ea27ca10de9ce2d3a519d7b8e2dc8dedf5078c4"
sha256 = "32f471f14b9f3cff21d57a3f69336717c697f8cd6bf502c7aa01591d69c42e90"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "df5deac92b0ff5b7b40251d3eee21c6d3c345fa62a6b726001a730c16fabbfb8"
sha256 = "d46933f539a253fce67e81c06b6bf9d295cc7805cd36842c9d189fcc57b17950"
[[asset]]
path = "assets/icons/accessibility.svg"
+23
View File
@@ -2798,6 +2798,29 @@ impl Session {
Self::refresh(doc)
}
/// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open
/// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA
/// layout and scripts, signatures, repairs), but the document keeps its id, name and path,
/// so its tab and view stay with it. Undo history goes, as with Revert. The password it was
/// opened with is tried; on any failure the document is left as it was.
pub fn reload(&mut self, id: DocId, bytes: Arc<Vec<u8>>) -> Result<(), OpenError> {
let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?;
let doc = &self.docs[old];
let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation);
let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone());
let new_id = self.open(name, path, bytes, password.as_deref())?;
let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?;
let mut doc = self.docs.remove(at);
doc.id = id;
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
// one; there is nothing new to autosave.
doc.generation = generation + 1;
doc.snapshot_generation = doc.generation;
// `open` pushed the new version last, so the old one is still at `old`.
*self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc;
Ok(())
}
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
pub fn page_count_of(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?)
+19
View File
@@ -1662,6 +1662,25 @@ fn added_images_rotate_flip_and_crop_as_drawn() {
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
}
#[test]
fn reload_reads_the_new_file_into_the_same_document() {
let (mut s, id) = session_with(2);
let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let before = s.get(id).unwrap().edit_generation();
s.reload(id, Arc::new(fixture(3))).unwrap();
let d = s.get(id).unwrap();
assert_eq!((d.id, d.name.as_str()), (id, "fixture.pdf"), "same document, same name");
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 3"], "the new version");
assert!(!d.dirty && d.can_undo().is_none(), "nothing unsaved, nothing to undo");
assert!(d.edit_generation() > before, "caches keyed by the generation see a change");
assert_eq!(s.docs().iter().map(|d| d.id).collect::<Vec<_>>(), [id, other], "no new tab, same order");
// A file that isn't a PDF (yet) leaves the document as it was.
assert!(s.reload(id, Arc::new(b"%PDF-1.7 half written".to_vec())).is_err());
assert_eq!(page_texts(&s, id).len(), 3);
assert_eq!(s.docs().len(), 2);
}
#[test]
fn revert_goes_back_to_the_saved_version() {
let (mut s, id) = session_with(2);
+63
View File
@@ -296,6 +296,9 @@ pub struct DocView {
pub highlight_fields: bool,
pub page_input: String,
pub notice_dismissed: bool,
/// The file changed on disk while this document had unsaved changes: the notice bar asks
/// before replacing it (#431).
pub disk_changed: bool,
/// Two-page view: show the first page alone, as a cover (View ▸ Page display).
pub cover: bool,
/// Previous view / Next view: pages visited before (and after, once going back).
@@ -547,6 +550,7 @@ impl DocView {
highlight_fields: defaults.highlight_fields,
page_input: "1".into(),
notice_dismissed: false,
disk_changed: false,
cover: false,
back: Vec::new(),
forward: Vec::new(),
@@ -649,6 +653,38 @@ impl DocView {
self.panel_drag = None;
}
/// The document was replaced by a newer version of its file (#431). The reader's place
/// stays: zoom, layout, rotation, page, scroll, view history and the find bar, and the page
/// images on screen until their replacements are drawn, so nothing flashes. Everything that
/// pointed into the old version (a selected comment, a field being typed in, text being
/// edited, a drag, page selection) starts afresh, so no stale index reaches the new one.
pub fn document_replaced(&mut self, info: &DocInfo) {
let mut fresh = Self::new(self.id, info, ViewDefaults::default());
fresh.zoom = self.zoom;
fresh.fit = self.fit;
fresh.layout = self.layout;
fresh.rotation = self.rotation;
fresh.current = self.current;
fresh.organize = self.organize;
fresh.grid_zoom = self.grid_zoom;
// Same document id, so the scroll offset egui keeps for it is still this document's.
fresh.shown = self.shown;
fresh.highlight_fields = self.highlight_fields;
fresh.notice_dismissed = self.notice_dismissed;
fresh.cover = self.cover;
fresh.back = std::mem::take(&mut self.back);
fresh.forward = std::mem::take(&mut self.forward);
fresh.find = self.find.take();
fresh.pages = std::mem::take(&mut self.pages);
fresh.thumbs = std::mem::take(&mut self.thumbs);
fresh.viewport_w = self.viewport_w;
fresh.viewport_h = self.viewport_h;
fresh.viewport_screen = self.viewport_screen;
*self = fresh;
// Marks the kept images out of date and clamps the page to the new page count.
self.document_changed(info);
}
/// Pages an organize command acts on: the selection, or the current page.
pub fn target_pages(&self) -> Vec<usize> {
if self.selected.is_empty() { vec![self.current] } else { self.selected.iter().copied().collect() }
@@ -1837,6 +1873,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
let repaired = !doc.repair_log().is_empty();
match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) {
#[cfg(not(target_arch = "wasm32"))]
Some(Notice::Reload) => app.watch.reload_request = Some(view.id),
#[cfg(target_arch = "wasm32")]
Some(Notice::Reload) => {}
Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)),
Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)),
Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures),
@@ -3149,6 +3189,8 @@ enum FieldMenu {
/// What the notice bar's buttons ask for.
enum Notice {
/// Reload the file that changed on disk, dropping unsaved changes.
Reload,
Security,
Signatures,
Repairs,
@@ -3169,6 +3211,27 @@ fn notices(
) -> Option<Notice> {
let info = &doc.info;
let xfa = doc.xfa.as_ref();
// The file changed on disk while this document has unsaved changes (#431): ask before
// replacing it, ahead of every other notice.
if view.disk_changed {
let mut reload = false;
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(icons::image("rotate-cw", 16.0, t.accent_text));
ui.label(
egui::RichText::new(tl!("This file changed on disk. Reload it to see the new version; your unsaved changes will be lost."))
.color(t.text),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() {
view.disk_changed = false;
}
reload = crate::widgets::pill_button(ui, tl!("Reload"), false).clicked();
});
});
});
return reload.then_some(Notice::Reload);
}
if let Some((icon, color, template, arg)) = signed {
let mut open = false;
notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| {
+4
View File
@@ -537,6 +537,8 @@ impl PdfCraftApp {
self.notify_fmt("Couldn't save {name}: {e}", &[("name", dest), ("e", &e.to_string())]);
return false;
}
// Our own write is not a change on disk to reload (#431).
self.watch.known_now(id, dest);
match self.session.mark_saved(id, bytes, Some(dest.to_string())) {
Ok(()) => {
self.forget_recovery(id);
@@ -544,6 +546,8 @@ impl PdfCraftApp {
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
{
view.document_changed(&doc.info);
// Saving over a version that changed on disk keeps this one: nothing to ask.
view.disk_changed = false;
}
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
true
+377
View File
@@ -0,0 +1,377 @@
//! Reload a document when its file changes on disk (#431): a LaTeX or Typst build rewriting the
//! PDF that is open shows its new version without reopening it.
//!
//! Cheap by design. One worker thread looks at each open file's metadata (no reading) every
//! [`IDLE`], and blocks without waking at all while no open document has a file. A changed file
//! is read only once it has stopped changing ([`SETTLE`] apart) and ends like a whole PDF: its
//! last KiB is read first, so a file still being written costs one small read. The new bytes go
//! to the UI thread, which is woken for them: a clean document is reloaded in place, keeping its
//! tab, page, zoom and scroll; one with unsaved changes gets a notice instead. PdfCraft's own
//! saves are recognised by the file's stamp after writing, so they never reload.
use std::collections::HashMap;
use std::fs::{File, Metadata};
use std::io::{Read, Seek, SeekFrom};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, SystemTime};
use pdfcraft_engine::DocId;
use crate::PdfCraftApp;
/// How often an unchanged file is looked at.
pub const IDLE: Duration = Duration::from_millis(250);
/// How long a changed file must stay the same before it is read: writers pause between chunks.
pub const SETTLE: Duration = Duration::from_millis(40);
/// How much of the end of a changed file is read to see whether it is a whole PDF.
const TAIL: usize = 1024;
/// One version of a file, told apart without reading it: its length and modification time, and
/// what the platform adds (Unix: device, inode and change time; Windows: creation and last-write
/// times in 100 ns ticks). The modification time alone is coarse on FAT and some network
/// drives, and a rebuild can keep the length.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stamp {
len: u64,
modified: Option<SystemTime>,
extra: [u64; 4],
}
impl Stamp {
fn of(meta: &Metadata) -> Self {
#[cfg(unix)]
let extra = {
use std::os::unix::fs::MetadataExt;
[meta.dev(), meta.ino(), meta.ctime().cast_unsigned(), meta.ctime_nsec().cast_unsigned()]
};
#[cfg(windows)]
let extra = {
use std::os::windows::fs::MetadataExt;
[meta.creation_time(), meta.last_write_time(), 0, 0]
};
#[cfg(not(any(unix, windows)))]
let extra = [0; 4];
Self { len: meta.len(), modified: meta.modified().ok(), extra }
}
/// The file at `path` now; `None` when it is missing, not a file or can't be read.
pub fn at(path: &str) -> Option<Self> {
std::fs::metadata(path).ok().filter(Metadata::is_file).map(|m| Self::of(&m))
}
}
/// A file to watch, as the UI thread sees it.
#[derive(Clone, Debug, PartialEq)]
pub struct Watched {
pub doc: DocId,
pub path: String,
/// The version PdfCraft last read or wrote (`None`: the first one the watcher sees).
pub known: Option<Stamp>,
}
/// A new version of a watched file, read and ready to open.
pub struct Changed {
pub doc: DocId,
pub stamp: Stamp,
pub bytes: Arc<Vec<u8>>,
}
/// The watcher's record of one file.
struct Entry {
doc: DocId,
path: String,
known: Option<Stamp>,
/// A changed version seen once, waiting to be seen unchanged again.
pending: Option<Stamp>,
}
/// Looks at the watched files: on the worker thread, or on the UI thread in tests.
#[derive(Default)]
pub struct Watcher {
files: Vec<Entry>,
}
impl Watcher {
/// Watch `list` from now on. A file watched already keeps its pending change; a version the
/// UI thread knows (it just read or saved the file) replaces the watcher's own.
fn set(&mut self, list: Vec<Watched>) {
let old = std::mem::take(&mut self.files);
self.files = list
.into_iter()
.map(|w| {
let prev = old.iter().find(|e| e.doc == w.doc && e.path == w.path);
Entry { known: w.known.or(prev.and_then(|e| e.known)), pending: prev.and_then(|e| e.pending), doc: w.doc, path: w.path }
})
.collect();
}
/// Look at every file once: the new versions found, and whether a file is still changing
/// (look again after [`SETTLE`] rather than [`IDLE`]).
fn tick(&mut self) -> (Vec<Changed>, bool) {
let mut found = Vec::new();
let mut settling = false;
for e in &mut self.files {
// Missing (deleted, or being replaced) or unreadable: the document stays as it is.
let Some(now) = Stamp::at(&e.path) else {
e.pending = None;
continue;
};
let known = *e.known.get_or_insert(now);
if now == known {
e.pending = None;
continue;
}
if e.pending != Some(now) {
// Changed since the last look: read it once it stops changing.
e.pending = Some(now);
settling = true;
continue;
}
e.pending = None;
match read_whole_pdf(&e.path, now) {
Version::Ready(bytes) => {
e.known = Some(now);
found.push(Changed { doc: e.doc, stamp: now, bytes });
}
// Not a whole PDF in this version (a build that failed or paused): wait for the
// next one.
Version::Incomplete => e.known = Some(now),
// Changed again while being read, or held by its writer: look again soon.
Version::Busy => settling = true,
}
}
(found, settling)
}
}
/// What a changed file turned out to be.
enum Version {
/// A whole PDF, read.
Ready(Arc<Vec<u8>>),
/// Not a whole PDF: a build that failed or paused.
Incomplete,
/// Changing, replaced or held by its writer: look again.
Busy,
}
/// Read `path` if it is still the version `stamp` and ends like a whole PDF (`%%EOF` in its last
/// KiB, read first). The whole file is then read into a buffer of exactly its size.
fn read_whole_pdf(path: &str, stamp: Stamp) -> Version {
// Windows refuses to open a file its writer holds exclusively: not ready yet.
let Ok(mut file) = File::open(path) else { return Version::Busy };
let Ok(meta) = file.metadata() else { return Version::Busy };
if Stamp::of(&meta) != stamp {
return Version::Busy;
}
let len = meta.len();
let mut end = [0u8; TAIL];
let tail = usize::try_from(len).map_or(TAIL, |l| l.min(TAIL));
let (Some(end), Ok(back)) = (end.get_mut(..tail), i64::try_from(tail)) else { return Version::Incomplete };
if file.seek(SeekFrom::End(-back)).is_err() || file.read_exact(end).is_err() {
return Version::Busy;
}
if !end.windows(5).any(|w| w == b"%%EOF") {
return Version::Incomplete;
}
let mut bytes = Vec::new();
// Too big to hold: the open version stays.
let Ok(size) = usize::try_from(len) else { return Version::Incomplete };
if bytes.try_reserve_exact(size).is_err() {
return Version::Incomplete;
}
if file.seek(SeekFrom::Start(0)).is_err() || file.read_to_end(&mut bytes).is_err() {
return Version::Busy;
}
// Still that version, under that name: a writer may have replaced the file meanwhile.
if bytes.len() != size || Stamp::at(path) != Some(stamp) {
return Version::Busy;
}
Version::Ready(Arc::new(bytes))
}
/// The worker thread: watches the newest list the UI thread sent and hands back new versions,
/// waking the UI for them. It ends when the app (its inbox's sender) is gone.
fn run(inbox: mpsc::Receiver<Vec<Watched>>, out: Arc<Mutex<Vec<Changed>>>, ctx: egui::Context) {
let mut watcher = Watcher::default();
// `None`: nothing to watch, so block until there is.
let mut wait = None;
loop {
let message = match wait {
None => inbox.recv().map_err(|_| RecvTimeoutError::Disconnected),
Some(wait) => inbox.recv_timeout(wait),
};
match message {
Ok(list) => watcher.set(list),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
while let Ok(list) = inbox.try_recv() {
watcher.set(list);
}
if watcher.files.is_empty() {
wait = None;
continue;
}
let (found, settling) = watcher.tick();
if !found.is_empty() {
out.lock().unwrap_or_else(PoisonError::into_inner).extend(found);
ctx.request_repaint();
}
wait = Some(if settling { SETTLE } else { IDLE });
}
}
/// The app's side of the watcher.
#[derive(Default)]
pub struct FileWatch {
/// The worker's inbox (`None` until a document with a file is open).
inbox: Option<mpsc::Sender<Vec<Watched>>>,
found: Arc<Mutex<Vec<Changed>>>,
/// What the watcher was last told.
sent: Vec<Watched>,
/// In tests ([`PdfCraftApp::run_inline`]) there is no thread: each frame looks once.
inline: Watcher,
/// The version of each document's file that PdfCraft last read or wrote.
known: HashMap<DocId, Stamp>,
/// The notice bar's Reload was pressed for this document.
pub reload_request: Option<DocId>,
}
impl FileWatch {
/// PdfCraft read or wrote `path` for `doc`: that version is not a change.
pub fn known_now(&mut self, doc: DocId, path: &str) {
if let Some(stamp) = Stamp::at(path) {
self.known.insert(doc, stamp);
}
}
/// Remember `stamp`, read before the file was, for `doc`.
pub fn known_as(&mut self, doc: DocId, stamp: Option<Stamp>) {
if let Some(stamp) = stamp {
self.known.insert(doc, stamp);
}
}
}
impl PdfCraftApp {
/// The documents to watch: every open one with a file, while the preference is on.
fn watched(&self) -> impl Iterator<Item = (DocId, &str, Option<Stamp>)> {
self.views.iter().filter(|_| self.reload_changed_files).filter_map(|v| {
let path = self.session.get(v.id)?.path.as_deref()?;
Some((v.id, path, self.watch.known.get(&v.id).copied()))
})
}
/// Every frame: tell the watcher about files opened, closed or saved (without allocating
/// when nothing changed), and reload what changed on disk.
pub(crate) fn watch_files(&mut self) {
if let Some(id) = self.watch.reload_request.take() {
self.reload_from_disk(id);
}
let mut sent = self.watch.sent.iter();
let unchanged = self.watched().all(|(doc, path, known)| sent.next().is_some_and(|w| w.doc == doc && w.path == path && w.known == known))
&& sent.next().is_none();
if !unchanged {
let list: Vec<Watched> = self.watched().map(|(doc, path, known)| Watched { doc, path: path.to_owned(), known }).collect();
let open: Vec<DocId> = self.views.iter().map(|v| v.id).collect();
self.watch.known.retain(|doc, _| open.contains(doc));
if self.send_to_watcher(list.clone()) {
self.watch.sent = list;
}
}
if self.run_inline {
let (found, _) = self.watch.inline.tick();
self.watch.found.lock().unwrap_or_else(PoisonError::into_inner).extend(found);
}
let found = std::mem::take(&mut *self.watch.found.lock().unwrap_or_else(PoisonError::into_inner));
for c in found {
self.file_changed(c);
}
}
/// Hand `list` to the watcher, starting it when needed. `false`: not delivered (no context
/// yet, or the worker couldn't start or ended); the next frame tries again.
fn send_to_watcher(&mut self, list: Vec<Watched>) -> bool {
if self.run_inline {
self.watch.inline.set(list);
return true;
}
if self.watch.inbox.is_none() {
if list.is_empty() {
return true;
}
let Some(ctx) = self.ctx.clone() else { return false };
let (tx, rx) = mpsc::channel();
let out = self.watch.found.clone();
if std::thread::Builder::new().name("pdfcraft-watch".into()).spawn(move || run(rx, out, ctx)).is_err() {
return false;
}
self.watch.inbox = Some(tx);
}
let delivered = self.watch.inbox.as_ref().is_some_and(|tx| tx.send(list).is_ok());
if !delivered {
// The worker ended (it panicked): start a new one next frame.
self.watch.inbox = None;
}
delivered
}
/// A watched file has a new version: reload its document, or ask first when it has unsaved
/// changes.
fn file_changed(&mut self, c: Changed) {
let Some(i) = self.views.iter().position(|v| v.id == c.doc) else { return };
// PdfCraft wrote this version itself (a save), or it was handled already.
if self.watch.known.get(&c.doc) == Some(&c.stamp) {
return;
}
self.watch.known.insert(c.doc, c.stamp);
if self.has_unsaved_work(i) {
if let Some(view) = self.views.get_mut(i) {
view.disk_changed = true;
}
return;
}
self.reload_view(i, c.bytes);
}
/// The notice bar's Reload: read the file now and replace the document, unsaved changes and
/// all.
fn reload_from_disk(&mut self, doc: DocId) {
let Some(i) = self.views.iter().position(|v| v.id == doc) else { return };
let Some(path) = self.session.get(doc).and_then(|d| d.path.clone()) else { return };
let stamp = Stamp::at(&path);
match std::fs::read(&path) {
Ok(bytes) => {
self.watch.known_as(doc, stamp);
self.reload_view(i, Arc::new(bytes));
}
Err(e) => {
let name = self.session.get(doc).map(|d| d.name.clone()).unwrap_or_default();
self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
}
}
}
/// Open `bytes` in place of tab `i`'s document, keeping the tab and the reader's place. A
/// version that can't be opened leaves the document as it was, and says so.
fn reload_view(&mut self, i: usize, bytes: Arc<Vec<u8>>) {
let Some(id) = self.views.get(i).map(|v| v.id) else { return };
match self.session.reload(id, bytes) {
Ok(()) => {
// Whatever was unsaved is gone, so there is nothing left to recover.
self.forget_recovery(id);
if let (Some(view), Some(doc)) = (self.views.get_mut(i), self.session.get(id)) {
view.document_replaced(&doc.info);
}
// What the form's scripts said while it opened, as on opening.
let out = self.session.take_js_output(id);
self.handle_js(id, out);
}
Err(e) => {
let name = self.session.get(id).map(|d| d.name.clone()).unwrap_or_default();
self.notify_fmt("Couldn't reload {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
}
}
}
}
+4
View File
@@ -2232,6 +2232,10 @@ key Space مسافة
Panels اللوحات
Resize panels تغيير حجم اللوحات
Resize panel window تغيير حجم نافذة اللوحة
Reload documents when their files change on disk إعادة تحميل المستندات عند تغيّر ملفاتها على القرص
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. تغيّر هذا الملف على القرص. أعد تحميله لعرض النسخة الجديدة؛ ستفقد تغييراتك غير المحفوظة.
Reload إعادة تحميل
Couldn't reload {name}: {e} تعذّرت إعادة تحميل {name}: {e}
Select page {label} تحديد الصفحة {label}
Rotate page {label} counterclockwise تدوير الصفحة {label} عكس اتجاه عقارب الساعة
Rotate page {label} clockwise تدوير الصفحة {label} باتجاه عقارب الساعة
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2174,6 +2174,10 @@ key Space Интервал
Resize panel window Преоразмери прозореца на панела
Tools Инструменти
Inspector Инспектор
Reload documents when their files change on disk Презареждай документите, когато файловете им се променят на диска
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Този файл е променен на диска. Презаредете го, за да видите новата версия; незаписаните ви промени ще бъдат загубени.
Reload Презареди
Couldn't reload {name}: {e} Неуспешно презареждане на {name}: {e}
Select page {label} Избиране на страница {label}
Rotate page {label} counterclockwise Завъртане на страница {label} обратно на часовниковата стрелка
Rotate page {label} clockwise Завъртане на страница {label} по часовниковата стрелка
Can't render this file because it contains an unexpected character in line 8 and column 16.
+4
View File
@@ -121,6 +121,10 @@
Fit one full page Celá jedna stránka
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče.
Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft
Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tento soubor se na disku změnil. Znovu jej načtěte, abyste viděli novou verzi; neuložené změny budou ztraceny.
Reload Znovu načíst
Couldn't reload {name}: {e} Soubor {name} nelze znovu načíst: {e}
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Vyberte obrázek PNG nebo JPEG (nejvýše 4 MiB). Průhlednost bude zachována.
Couldn't import the signature image: {e} Obrázek podpisu nelze importovat: {e}
Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Přenosný režim je vypnutý: do složky {folder} nelze zapisovat ({error}). Nastavení se místo toho ukládá do vaší uživatelské složky.
Can't render this file because it contains an unexpected character in line 7 and column 81.
+4
View File
@@ -2216,6 +2216,10 @@ key Space Leertaste
Resize panel window Größe des Bereichsfensters ändern
Tools Werkzeuge
Inspector Inspektor
Reload documents when their files change on disk Dokumente neu laden, wenn sich ihre Dateien auf dem Datenträger ändern
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Diese Datei wurde auf dem Datenträger geändert. Lade sie neu, um die neue Version zu sehen; deine nicht gespeicherten Änderungen gehen dabei verloren.
Reload Neu laden
Couldn't reload {name}: {e} {name} konnte nicht neu geladen werden: {e}
Mouse wheel scroll speed Scrollgeschwindigkeit des Mausrads
scroll speed Slow Langsam
scroll speed Normal Normal
Can't render this file because it contains an unexpected character in line 6 and column 51.
+4
View File
@@ -2022,6 +2022,10 @@
Default zoom Zoom predeterminado
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página.
Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez
Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Este archivo ha cambiado en el disco. Recárgalo para ver la nueva versión; se perderán tus cambios sin guardar.
Reload Recargar
Couldn't reload {name}: {e} No se pudo recargar {name}: {e}
Created a PDF from {n} files Se creó un PDF a partir de {n} archivos
PDF, images and text PDF, imágenes y texto
Insert a file before page {label} Insertar un archivo antes de la página {label}
Can't render this file because it contains an unexpected character in line 6 and column 60.
+4
View File
@@ -1948,6 +1948,10 @@
Default zoom Zoom par défaut
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre.
Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft
Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ce fichier a été modifié sur le disque. Rechargez-le pour voir la nouvelle version ; vos modifications non enregistrées seront perdues.
Reload Recharger
Couldn't reload {name}: {e} Impossible de recharger {name} : {e}
Show Afficher
Sort Trier
Reverse the order Inverser l'ordre
Can't render this file because it contains an unexpected character in line 6 and column 39.
+4
View File
@@ -2210,6 +2210,10 @@ key Space Szóköz
Resize panel window Panelablak átméretezése
Tools Eszközök
Inspector Tulajdonságvizsgáló
Reload documents when their files change on disk Dokumentumok újratöltése, ha a fájljuk megváltozik a lemezen
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ez a fájl megváltozott a lemezen. Töltsd újra az új változat megtekintéséhez; a nem mentett módosításaid elvesznek.
Reload Újratöltés
Couldn't reload {name}: {e} {name} újratöltése nem sikerült: {e}
Select page {label} {label}. oldal kijelölése
Rotate page {label} counterclockwise {label}. oldal elforgatása az óramutató járásával ellentétesen
Rotate page {label} clockwise {label}. oldal elforgatása az óramutató járásával megegyezően
Can't render this file because it contains an unexpected character in line 7 and column 44.
+4
View File
@@ -2219,6 +2219,10 @@ key Space Spazio
Panels Pannelli
Resize panels Ridimensiona pannelli
Resize panel window Ridimensiona finestra pannello
Reload documents when their files change on disk Ricarica i documenti quando i loro file cambiano sul disco
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Questo file è cambiato sul disco. Ricaricalo per vedere la nuova versione; le modifiche non salvate andranno perse.
Reload Ricarica
Couldn't reload {name}: {e} Impossibile ricaricare {name}: {e}
Select page {label} Seleziona pagina {label}
Rotate page {label} counterclockwise Ruota pagina {label} in senso antiorario
Rotate page {label} clockwise Ruota pagina {label} in senso orario
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2212,6 +2212,10 @@ key Space スペース
Resize panel window パネルウィンドウのサイズを変更
Tools ツール
Inspector インスペクター
Reload documents when their files change on disk ディスク上のファイルが変更されたら文書を再読み込みする
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. このファイルはディスク上で変更されました。再読み込みすると新しいバージョンが表示されますが、未保存の変更は失われます。
Reload 再読み込み
Couldn't reload {name}: {e} {name} を再読み込みできませんでした: {e}
Select page {label} ページ {label} を選択
Rotate page {label} counterclockwise ページ {label} を反時計回りに回転
Rotate page {label} clockwise ページ {label} を時計回りに回転
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2205,6 +2205,10 @@ key Space Spacja
Text used as a clipping path cannot be moved with this selection. Tekstu używanego jako ścieżka przycinania nie można przenieść razem z tym zaznaczeniem.
These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia.
This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem.
Reload documents when their files change on disk Wczytuj ponownie dokumenty, gdy ich pliki zmienią się na dysku
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ten plik zmienił się na dysku. Wczytaj go ponownie, aby zobaczyć nową wersję; niezapisane zmiany zostaną utracone.
Reload Wczytaj ponownie
Couldn't reload {name}: {e} Nie można ponownie wczytać {name}: {e}
Select page {label} Wybierz stronę {label}
Rotate page {label} counterclockwise Obróć stronę {label} w lewo
Rotate page {label} clockwise Obróć stronę {label} w prawo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2213,6 +2213,10 @@ key Space Espaço
Resize panel window Redimensionar janela do painel
Tools Ferramentas
Inspector Inspetor
Reload documents when their files change on disk Recarregar documentos quando os arquivos mudarem no disco
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Este arquivo foi alterado no disco. Recarregue-o para ver a nova versão; suas alterações não salvas serão perdidas.
Reload Recarregar
Couldn't reload {name}: {e} Não foi possível recarregar {name}: {e}
Select page {label} Selecionar página {label}
Rotate page {label} counterclockwise Girar página {label} no sentido anti-horário
Rotate page {label} clockwise Girar página {label} no sentido horário
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -2017,6 +2017,10 @@
Default zoom Масштаб по умолчанию
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами.
Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft
Reload documents when their files change on disk Перезагружать документы при изменении их файлов на диске
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Этот файл был изменён на диске. Перезагрузите его, чтобы увидеть новую версию; несохранённые изменения будут потеряны.
Reload Перезагрузить
Couldn't reload {name}: {e} Не удалось перезагрузить {name}: {e}
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Выберите изображение PNG или JPEG (до 4 МиБ). Прозрачность сохраняется.
Couldn't import the signature image: {e} Не удалось импортировать изображение подписи: {e}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -1939,6 +1939,10 @@ action wizard Start ప్రారంభించు
Default zoom డిఫాల్ట్ జూమ్
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు.
Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు
Reload documents when their files change on disk డిస్క్‌లో వాటి ఫైల్‌లు మారినప్పుడు పత్రాలను మళ్లీ లోడ్ చేయి
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. ఈ ఫైల్ డిస్క్‌లో మారింది. కొత్త వెర్షన్‌ను చూడటానికి దాన్ని మళ్లీ లోడ్ చేయండి; సేవ్ చేయని మీ మార్పులు పోతాయి.
Reload మళ్లీ లోడ్ చేయి
Couldn't reload {name}: {e} {name}ను మళ్లీ లోడ్ చేయడం సాధ్యం కాలేదు: {e}
About పరిచయం
Contributors సహకారులు
Models మోడల్‌లు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+4
View File
@@ -2233,6 +2233,10 @@ key Space Пробіл
Panels Панелі
Resize panels Змінити розмір панелей
Resize panel window Змінити розмір вікна панелі
Reload documents when their files change on disk Перезавантажувати документи, коли їхні файли змінюються на диску
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Цей файл змінено на диску. Перезавантажте його, щоб побачити нову версію; незбережені зміни буде втрачено.
Reload Перезавантажити
Couldn't reload {name}: {e} Не вдалося перезавантажити {name}: {e}
Select page {label} Вибрати сторінку {label}
Rotate page {label} counterclockwise Повернути сторінку {label} проти годинникової стрілки
Rotate page {label} clockwise Повернути сторінку {label} за годинниковою стрілкою
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2051,6 +2051,10 @@
Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。
Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件
Reload documents when their files change on disk 文件在磁盘上更改时重新加载对应的文档
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 此文件已在磁盘上更改。请重新加载以查看新版本;未保存的更改将会丢失。
Reload 重新加载
Couldn't reload {name}: {e} 无法重新加载{name}:{e}
Write this email? 是否撰写此邮件?
{who} in this document wants to open this address in your browser: 此文档中的{who}请求在浏览器中打开以下地址:
{who} in this document wants to open your email app with this address: 此文档中的{who}请求打开邮件应用并使用以下地址:
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -2097,6 +2097,10 @@ key Space 空格
Resize panel window 調整面板視窗大小
Tools 工具
Inspector 檢查器
Reload documents when their files change on disk 檔案在磁碟上變更時重新載入對應的文件
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 此檔案已在磁碟上變更。請重新載入以查看新版本;未儲存的變更將會遺失。
Reload 重新載入
Couldn't reload {name}: {e} 無法重新載入{name}:{e}
Select page {label} 選取頁面 {label}
Rotate page {label} counterclockwise 逆時針旋轉頁面 {label}
Rotate page {label} clockwise 順時針旋轉頁面 {label}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -327,6 +327,9 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
// The web build has no file paths to reopen.
#[cfg(not(target_arch = "wasm32"))]
ui.checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"));
// The web build has no files on disk to watch.
#[cfg(not(target_arch = "wasm32"))]
ui.checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"));
ui.horizontal(|ui| {
ui.label(tl!("Mouse wheel scroll speed"));
for speed in crate::scroll_speed::ScrollSpeed::ALL {
+24
View File
@@ -72,6 +72,8 @@ pub mod bulk_fields;
mod dialogs;
mod edit_text_ui;
mod editing;
#[cfg(not(target_arch = "wasm32"))]
pub mod file_watch;
mod files;
pub mod fill_sign;
pub mod folders_ui;
@@ -474,6 +476,11 @@ pub struct PdfCraftApp {
pub last_session: last_session::LastSession,
/// Quitting closes unsaved tabs one by one: what was open when the quit began.
quit_session: Option<last_session::LastSession>,
/// Preferences: reload a document when its file changes on disk (#431).
pub reload_changed_files: bool,
/// Open documents' files, watched for changes on disk.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) watch: file_watch::FileWatch,
/// Folders pinned to Home, and what they held when last listed.
pub pinned: folders_ui::PinnedFolders,
pub toast: Option<(String, f64)>,
@@ -777,6 +784,9 @@ impl PdfCraftApp {
reopen_last_session: false,
last_session: Default::default(),
quit_session: None,
reload_changed_files: true,
#[cfg(not(target_arch = "wasm32"))]
watch: Default::default(),
pinned: Default::default(),
toast: None,
integrated_titlebar: false,
@@ -1211,10 +1221,17 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path(&mut self, path: &str) {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
let stamp = file_watch::Stamp::at(path);
let tabs = self.views.len();
match std::fs::read(path) {
Ok(bytes) => {
if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
} else if self.views.len() > tabs
&& let Some(doc) = self.views.last().map(|v| v.id)
{
self.watch.known_as(doc, stamp);
}
}
Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
@@ -1528,6 +1545,7 @@ impl PdfCraftApp {
"reopen_last_session": self.reopen_last_session,
// Kept only while the preference is on.
"last_session": self.reopen_last_session.then(|| self.session_to_save()),
"reload_changed_files": self.reload_changed_files,
"pinned_folders": self.pinned.folders,
"theme": self.theme_preference,
"scroll_speed": self.scroll_speed,
@@ -1605,6 +1623,9 @@ impl PdfCraftApp {
self.reopen_last_session = on;
}
self.last_session = last_session::LastSession::from_json(&v["last_session"]);
if let Some(on) = v["reload_changed_files"].as_bool() {
self.reload_changed_files = on;
}
self.pinned.restore(&v["pinned_folders"]);
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
self.set_theme_preference(preference);
@@ -2229,6 +2250,9 @@ impl eframe::App for PdfCraftApp {
self.guard_quit(ctx);
let now = ctx.input(|i| i.time);
self.autosave_tick(now);
// Reload documents whose files a build rewrote (#431).
#[cfg(not(target_arch = "wasm32"))]
self.watch_files();
if self.host_dirty.is_some() {
let dirty = self.first_dirty().is_some();
if let Some(report) = self.host_dirty.as_mut() {
+3
View File
@@ -424,6 +424,9 @@ impl PdfCraftApp {
}
let signed = app.session.sign(doc_id, &id, opts).map_err(|e| e.to_string())?;
crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?;
// Our own write is not a change on disk to reload (#431).
#[cfg(not(target_arch = "wasm32"))]
app.watch.known_now(doc_id, &path.to_string_lossy());
app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?;
app.sign_graphic = choice;
if let Some(view) = app.views.iter_mut().find(|v| v.id == doc_id) {
+206
View File
@@ -0,0 +1,206 @@
//! A document reloads when its file changes on disk (#431): a LaTeX or Typst build rewriting the
//! open PDF. Here the watcher looks once per frame on the test's thread (`run_inline`); in the
//! app a worker thread looks every quarter second.
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{PdfCraftApp, SaveTarget};
/// An `n`-page document with a proper xref table; page `i` shows "Page i+1", and with `notes`
/// each page has a sticky note.
fn fixture_with(n: usize, notes: bool) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
let annots = if notes { " /Annots [<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (note) >>]" } else { "" };
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >>{annots} >>", 5 + 2 * i));
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn fixture(n: usize) -> Vec<u8> {
fixture_with(n, false)
}
/// A PDF in a fresh temp folder, removed when the test ends.
struct TempPdf {
dir: PathBuf,
path: String,
builds: std::cell::Cell<u64>,
}
impl TempPdf {
fn new(test: &str, bytes: &[u8]) -> Self {
let dir = std::env::temp_dir().join(format!("pdfcraft-reload-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("thesis.pdf").to_string_lossy().into_owned();
std::fs::write(&path, bytes).unwrap();
Self { dir, path, builds: std::cell::Cell::new(0) }
}
/// Rewrite it as a build does: new contents, and a later modification time every time.
fn rebuild(&self, bytes: &[u8]) {
std::fs::write(&self.path, bytes).unwrap();
self.builds.set(self.builds.get() + 1);
let file = std::fs::File::options().write(true).open(&self.path).unwrap();
file.set_modified(SystemTime::now() + Duration::from_secs(60 * self.builds.get())).unwrap();
}
}
impl Drop for TempPdf {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn harness(path: &str) -> Harness<'static, PdfCraftApp> {
let path = path.to_owned();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.run_inline = true;
app.open_path(&path);
app
});
h.run_steps(4);
h
}
fn pages(h: &Harness<'static, PdfCraftApp>) -> usize {
let app = h.state();
app.session.get(app.views[0].id).unwrap().info.pages.len()
}
fn dirty(h: &Harness<'static, PdfCraftApp>) -> bool {
let app = h.state();
app.session.get(app.views[0].id).unwrap().dirty
}
fn rotate_first_page(h: &mut Harness<'static, PdfCraftApp>) {
h.state_mut().views[0].select_pages(&[0]);
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
}
#[test]
fn a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom() {
let file = TempPdf::new("rebuild", &fixture(3));
let mut h = harness(&file.path);
let id = h.state().views[0].id;
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::None;
h.state_mut().views[0].zoom = 1.5;
h.state_mut().views[0].go_to_page(2);
h.run_steps(4);
file.rebuild(&fixture(4));
// One look sees the change, the next sees it settled and reloads.
h.run_steps(3);
let app = h.state();
assert_eq!(pages(&h), 4, "the new version shows");
assert_eq!((app.views.len(), app.views[0].id), (1, id), "in the same tab");
assert_eq!((app.views[0].current, app.views[0].zoom), (2, 1.5), "at the same page and zoom");
}
#[test]
fn a_half_written_file_waits_until_the_pdf_is_whole() {
let file = TempPdf::new("partial", &fixture(2));
let mut h = harness(&file.path);
let whole = fixture(5);
// A build part-way through: whole objects up to the fourth page, no cross-reference table
// or end yet. The lenient parser would open this as a document with pages missing.
let cut = whole.windows(9).position(|w| w == b"\n10 0 obj").unwrap();
file.rebuild(&whole[..cut]);
h.run_steps(4);
assert_eq!(pages(&h), 2, "half a file is not loaded");
file.rebuild(&whole);
h.run_steps(3);
assert_eq!(pages(&h), 5, "the whole one is");
}
#[test]
fn saving_does_not_reload_or_lose_undo() {
let file = TempPdf::new("save", &fixture(2));
let mut h = harness(&file.path);
rotate_first_page(&mut h);
h.state_mut().save_active(SaveTarget::InPlace);
h.run_steps(4);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert!(!doc.dirty, "saved");
assert!(doc.can_undo().is_some(), "a reload would have cleared the undo history");
}
#[test]
fn unsaved_changes_are_kept_until_reload_is_chosen() {
let file = TempPdf::new("dirty", &fixture(2));
let mut h = harness(&file.path);
rotate_first_page(&mut h);
file.rebuild(&fixture(4));
h.run_steps(3);
assert_eq!((pages(&h), dirty(&h)), (2, true), "not replaced behind the user's back");
h.get_by_label_contains("This file changed on disk");
h.get_by_label("Reload").click();
h.run_steps(3);
assert_eq!((pages(&h), dirty(&h)), (4, false), "replaced when asked");
assert!(h.query_by_label_contains("This file changed on disk").is_none());
}
#[test]
fn a_deleted_file_leaves_the_document_open() {
let file = TempPdf::new("deleted", &fixture(2));
let mut h = harness(&file.path);
std::fs::remove_file(&file.path).unwrap();
h.run_steps(4);
assert_eq!((h.state().views.len(), pages(&h)), (1, 2), "still open, as it was");
file.rebuild(&fixture(3));
h.run_steps(3);
assert_eq!(pages(&h), 3, "and the next build shows");
}
#[test]
fn nothing_reloads_with_the_preference_off() {
assert!(PdfCraftApp::new().reload_changed_files, "on by default");
let file = TempPdf::new("off", &fixture(2));
let mut h = harness(&file.path);
h.state_mut().reload_changed_files = false;
h.run_steps(1);
file.rebuild(&fixture(3));
h.run_steps(4);
assert_eq!(pages(&h), 2);
let mut restarted = PdfCraftApp::new();
restarted.restore(&h.state().persist());
assert!(!restarted.reload_changed_files, "the choice is kept");
}
#[test]
fn a_shorter_new_version_drops_what_pointed_into_the_old_one() {
let file = TempPdf::new("shorter", &fixture_with(5, true));
let mut h = harness(&file.path);
h.state_mut().views[0].go_to_page(4);
h.state_mut().views[0].select_pages(&[3, 4]);
h.state_mut().views[0].comments.selected = Some((4, 0));
h.run_steps(2);
file.rebuild(&fixture(1));
h.run_steps(4);
let view = &h.state().views[0];
assert_eq!(pages(&h), 1);
assert_eq!(view.current, 0, "the page is clamped");
assert!(view.selected.is_empty() && view.comments.selected.is_none(), "no selection points past the new version");
}
+12
View File
@@ -625,6 +625,18 @@ tools = []
evidence = ["crates/ui-egui/tests/viewer.rs::revert_after_confirming", "crates/engine/src/tests.rs::revert_goes_back_to_the_saved_version"]
notes = "with confirmation; clears undo, as Acrobat"
[[feature]]
id = "core.reload-changed-files"
title = "Reload a document when its file changes on disk"
area = "A"
tier = "P2"
milestone = "M4"
status = "shipped"
commands = []
tools = []
evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one"]
notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert."
[[feature]]
id = "core.signed-save-policy"
title = "Force incremental saves for signed documents"