History

fix(overleaf): compile the writer's chosen main document and allow PDF notes as Overleaf comments (#656)

* fix(overleaf): compile Overleaf's main document, heal a root a sync removed, and annotate the PDF as Overleaf comments Opening an Overleaf project whose main.tex had become main_v2.tex on Overleaf showed "No such file or directory (os error 2)". Trigger: a sync applies Overleaf's rename/delete of the root document locally (a local delete of the root is refused, a sync's is not), but .research/project.json kept naming main.tex. Masking: a build from main_v2.tex itself still worked (the open file wins the compile), and a fresh download detects a root from the files. Symptom: the workspace restore opens the manifest's root, and read_file returned the raw io error for the missing file. - project::open replaces a missing root in a linked project with Overleaf's main document as last followed, else detection, and drops the roots that are gone; a local folder keeps an absent root (mid-checkout) and now says which file is missing and where to choose another. - The live channel reads rootDoc_id from joinProject and follows rootDocUpdated and renames; overleaf_adopt_root_document makes it the compiled document when it changed, so a project holding both main.tex and main_v2.tex builds what Overleaf builds. A main document the writer sets here (new "Set as main document" in a .tex file's menu) stands until Overleaf's changes. - The workspace restore opens the default root instead of preferring any file called main.tex. - PDF Annotate was switched off in every project linked to Overleaf. It now turns a note into an Overleaf comment on the span SyncTeX found, and says why when the passage is in a file that is not live with Overleaf. - projectOverleafEditorComments moves to src/overleaf/overleaf-editor-comments.ts so use-overleaf-workspace.ts can mount the new hook within its size budget. * no-mistakes(review): Stop auto-following Overleaf root; drop missing roots * no-mistakes(review): Keep a chosen main document over the open tab * no-mistakes(review): Let the menu pin the current default main document * no-mistakes(review): Keep a PDF note open when Overleaf goes offline * no-mistakes(review): Anchor PDF notes for Overleaf in the live text * no-mistakes(review): Fill an empty linked root list on the next open * no-mistakes(test): Fix root-doc type error and PDF composer destination * no-mistakes(document): Update workspace IPC command count in architecture

Leo committed 1 day agomain

Commit 3f6bcd41 parent 38fe1ac43 files · +880 −123Browse files

Unified · Split

43 files
  1. docs/architecture.md +1 −1
  2. src-tauri/src/ipc/overleaf_realtime.rs +53 −6
  3. src-tauri/src/ipc/workspace.rs +19 −0
  4. src-tauri/src/latex/build.rs +10 −4
  5. src-tauri/src/latex/compile_root.rs +41 −5
  6. src-tauri/src/latex/synctex/tests.rs +1 −0
  7. src-tauri/src/lib.rs +1 −0
  8. src-tauri/src/models.rs +4 −0
  9. src-tauri/src/overleaf.rs +2 −0
  10. src-tauri/src/overleaf/link.rs +4 −0
  11. src-tauri/src/overleaf/root_document.rs +153 −0
  12. src-tauri/src/overleaf_rt/client.rs +6 −24
  13. src-tauri/src/overleaf_rt/events.rs +2 −0
  14. src-tauri/src/overleaf_rt/tree.rs +94 −2
  15. src-tauri/src/project.rs +3 −2
  16. src-tauri/src/project/create.rs +1 −0
  17. src-tauri/src/project/manifest.rs +56 −1
  18. src-tauri/src/project/templates.rs +1 −0
  19. src-tauri/src/project/tree.rs +6 −1
  20. src/App.tsx +1 −1
  21. src/app-types.ts +2 −0
  22. src/app/adopt-root-document.ts +3 −1
  23. src/app/annotation-rounds.test.ts +38 −4
  24. src/app/annotation-rounds.ts +17 −8
  25. src/app/app-overleaf-drawer.tsx +2 −1
  26. src/app/use-editor-comments.test.ts +30 −0
  27. src/app/use-editor-comments.ts +29 −4
  28. src/app/use-overleaf-workspace.test.ts +2 −1
  29. src/app/use-overleaf-workspace.ts +3 −43
  30. src/app/use-panel-portals.tsx +15 −2
  31. src/app/workspace-restore.test.ts +11 −0
  32. src/app/workspace-restore.ts +3 −3
  33. src/canvas/annotation-composer.tsx +6 −4
  34. src/canvas/pdf-annotation-composer.test.tsx +78 −0
  35. src/canvas/pdf-annotation-composer.tsx +24 −3
  36. src/locales/en/messages.po +28 −0
  37. src/locales/zh-CN/messages.po +28 −0
  38. src/overleaf/overleaf-editor-comments.ts +47 −0
  39. src/platform/host-error-text.test.ts +4 −0
  40. src/platform/host-errors-setup.ts +1 −1
  41. src/platform/host-errors-workspace.ts +2 −0
  42. src/project/project-file-tree.test.tsx +32 −0
  43. src/project/project-file-tree.tsx +16 −1
docs/architecture.md+1−1
@@ -290,7 +290,7 @@ Practical consequence: to find what a button does, grep the command name in · 289 unmodified lines
290290 | `search` | 10 | `search_project`, `replace_in_project`, `rename_symbol`, `list_todos` |
291291 | `windows` | 11 | `open_project_window`, `open_in_browser`, `return_to_desktop`, `set_window_material` |
292292 | `bibliography` | 16 | `list_citations`, `save_bib_entry`, `bibliography_audit_scan`, `agent_bibliography_mutation` |
293−| `workspace` | 18 | `create_project`, `open_project`, `import_project_zip`, `check_arxiv_package`, `update_project_manifest` |
293+| `workspace` | 20 | `create_project`, `open_project`, `import_project_zip`, `check_arxiv_package`, `update_project_manifest` |
294294 | `papers` | 12 | `search_literature`, `fetch_paper`, `import_reference`, `read_paper` |
295295 | `git` | 10 | `git_status`, `git_user_name`, `git_log`, `git_show_diff`, `git_compare_files`, `git_restore_project`, `git_auto_commit` |
296296 | `history` | 5 | `list_history`, `get_history_entry`, `revert_transaction` |
src-tauri/src/ipc/overleaf_realtime.rs+53−6
@@ -5,12 +5,12 @@ · 4 unmodified lines
55
66 use super::overleaf::{overleaf_config_dir, settled};
77 use super::{current_root, pinned_root, run_blocking, scoped_root};
8−use crate::app_state::{AppState, Lease, ProjectLease};
8+use crate::app_state::{AppState, Lease, ProjectLease, ProjectResources};
99 use crate::overleaf;
10−use crate::overleaf_rt::{self, RealtimeClient};
10+use crate::overleaf_rt::{self, RealtimeClient, RealtimeEvent};
1111 use std::collections::{BTreeMap, BTreeSet};
1212 use std::path::{Path, PathBuf};
13−use std::sync::Arc;
13+use std::sync::{Arc, Weak};
1414 use tauri::{AppHandle, Emitter, State, Window};
1515
1616 #[derive(Default)]
@@ -25,6 +25,9 @@ pub(crate) struct OverleafRealtimeState { · 8 unmodified lines
2525 /// the same lease that prevents a new join until the sync finishes.
2626 joined_paths: BTreeMap<String, String>,
2727 join_receipts: BTreeMap<String, String>,
28+ /// Overleaf's main document as this connection last reported it (the join,
29+ /// then each tree change), which `record_root_document` writes to the link.
30+ root_document: Option<String>,
2831 }
2932
3033 impl OverleafRealtimeState {
@@ -35,6 +38,7 @@ impl OverleafRealtimeState { · 4 unmodified lines
3538 self.root = root;
3639 self.joined_paths.clear();
3740 self.join_receipts.clear();
41+ self.root_document = None;
3842 self.client.take()
3943 }
4044
@@ -175,13 +179,27 @@ pub async fn overleaf_rt_connect( · 134 unmodified lines
175179 }
176180
177181 let event_state = Arc::clone(&project.realtime);
182+ let event_project = Arc::downgrade(&project);
178183 let event_root = root.clone();
179184 let emit_label = window.label().to_string();
180185 let emitter = app.clone();
181186 let connecting = RealtimeClient::connect(
182187 overleaf_rt::RealtimeConfig { user_id, host, cookie, project_id },
183188 move |event| {
184− if event_state.lock().is_ok_and(|realtime| realtime.owns(generation, &event_root)) {
189+ let mut reported = false;
190+ let owned = event_state.lock().is_ok_and(|mut realtime| {
191+ let owns = realtime.owns(generation, &event_root);
192+ if let (true, RealtimeEvent::TreeChanged { root_document, .. }) = (owns, &event) {
193+ reported = realtime.root_document != *root_document;
194+ realtime.root_document = root_document.clone();
195+ }
196+ owns
197+ });
198+ if reported {
199+ let (project, root) = (Weak::clone(&event_project), event_root.clone());
200+ tauri::async_runtime::spawn(record_root_document(project, root, generation));
201+ }
202+ if owned {
185203 let event = ScopedEvent { project_root: &event_root, event };
186204 let _ = emitter.emit_to(emit_label.as_str(), "overleaf-realtime", event);
187205 }
@@ -213,6 +231,7 @@ pub async fn overleaf_rt_connect( · 25 unmodified lines
213231 let mut realtime = project.realtime()?;
214232 if realtime.owns(generation, &root) {
215233 realtime.client = Some(Arc::clone(&client));
234+ realtime.root_document = tree.root_document.clone();
216235 installed = true;
217236 }
218237 }
@@ -227,17 +246,45 @@ pub async fn overleaf_rt_connect( · 8 unmodified lines
227246 // that was already under way.
228247 let root_folder_id = tree.root_folder_id.clone();
229248 let permission = tree.permission.as_str().to_string();
230− let _lease = ProjectLease::take(project, Lease::Exclusive).await;
249+ let lease = ProjectLease::take(project, Lease::Exclusive).await;
231250 if current_root(&state, &window)? != root {
232251 return Err("The project changed before Overleaf could finish connecting.".to_string());
233252 }
253+ let root_document = reported_root_document(&lease.project, generation, &root)?;
234254 run_blocking("The Overleaf metadata update", move || {
235− overleaf::set_realtime_metadata(&root, &root_folder_id, &permission)
255+ overleaf::set_realtime_metadata(&root, &root_folder_id, &permission)?;
256+ match root_document {
257+ Some(path) => overleaf::record_root_document(&root, path.as_deref()),
258+ None => Ok(()),
259+ }
236260 })
237261 .await?;
238262 Ok(joined)
239263 }
240264
265+/// Overleaf's main document as connection `generation` of `root` last reported
266+/// it; `None` once another connection replaced it. Read under the project lease
267+/// rather than carried from the report, so a later report recorded first is
268+/// never overwritten by an earlier one.
269+fn reported_root_document(
270+ project: &ProjectResources, generation: u64, root: &Path,
271+) -> Result<Option<Option<String>>, String> {
272+ let realtime = project.realtime()?;
273+ Ok(realtime.owns(generation, root).then(|| realtime.root_document.clone()))
274+}
275+
276+/// Record the main document a tree change reported in the link state, which
277+/// the sync rewrites too; one at a time.
278+async fn record_root_document(project: Weak<ProjectResources>, root: PathBuf, generation: u64) {
279+ let Some(project) = project.upgrade() else { return };
280+ let lease = ProjectLease::take(project, Lease::Exclusive).await;
281+ let Ok(Some(path)) = reported_root_document(&lease.project, generation, &root) else { return };
282+ let _ = run_blocking("Overleaf main document", move || {
283+ overleaf::record_root_document(&root, path.as_deref())
284+ })
285+ .await;
286+}
287+
241288 #[tauri::command]
242289 pub fn overleaf_rt_disconnect(
243290 state: State<'_, AppState>, window: Window, project_root: Option<String>,
src-tauri/src/ipc/workspace.rs+19−0
@@ -234,6 +234,25 @@ pub async fn update_project_manifest( · 233 unmodified lines
234234 .await
235235 }
236236
237+/// Set as main document: the .tex file builds compile from now on.
238+#[tauri::command]
239+pub async fn set_root_document(
240+ state: State<'_, AppState>, window: Window, project_root: String, path: String,
241+) -> Result<ProjectManifest, String> {
242+ let root = pinned_root(&state, &window, &project_root, "the main document could be set")?;
243+ run_blocking("Main document update", move || {
244+ let relative = path.trim().replace('\\', "/");
245+ let file = project::safe_path(&root, &relative);
246+ if !relative.to_ascii_lowercase().ends_with(".tex")
247+ || !file.is_ok_and(|file| file.is_file())
248+ {
249+ return Err(format!("{relative} is not a LaTeX file in this project."));
250+ }
251+ project::choose_root_document(&root, &relative)
252+ })
253+ .await
254+}
255+
237256 #[tauri::command]
238257 pub async fn set_project_deadline(
239258 state: State<'_, AppState>, window: Window, deadline: Option<ProjectDeadline>,
src-tauri/src/latex/build.rs+10−4
@@ -156,10 +156,10 @@ pub fn classify_build_error(error: &str) -> Failure { · 155 unmodified lines
156156 }
157157 } else if error == ALREADY_RUNNING {
158158 Failure { kind: "build_busy", fix: "Wait for the running build, or stop it, then retry." }
159− } else if error.starts_with("Root document not found") {
159+ } else if error.contains(", the main document, is missing.") {
160160 Failure {
161161 kind: "root_document_missing",
162− fix: "Open the main .tex file and build from it, or fix the compile root.",
162+ fix: "Set an existing .tex file as the main document, or build from it.",
163163 }
164164 } else if error.starts_with("The project changed") {
165165 Failure { kind: "project_changed", fix: "Build again in the project now open." }
@@ -170,6 +170,12 @@ pub fn classify_build_error(error: &str) -> Failure { · 4 unmodified lines
170170
171171 const ALREADY_RUNNING: &str = "A build is already running.";
172172
173+/// The manifest names a main document that is not on disk (see
174+/// `project::open`, which replaces one it can): say which, and where to choose.
175+fn missing_root(document: &str) -> String {
176+ format!("{document}, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document.")
177+}
178+
173179 pub fn build(
174180 root: &Path, force: bool, active: &ActiveBuild, open_document: Option<&str>, steps: &BuildSteps,
175181 ) -> Result<BuildResult, String> {
@@ -316,7 +322,7 @@ fn run_latexmk_with( · 140 unmodified lines
316322 let document = compiled_document(root, &manifest)?;
317323 let root_document = project::safe_path(root, &document)?;
318324 if !root_document.exists() {
319− return Err(format!("Root document not found: {document}"));
325+ return Err(missing_root(&document));
320326 }
321327 let prepared = if use_format {
322328 preamble_format::prepare(root, &manifest, &document, &root_document, active, steps)
@@ -497,7 +503,7 @@ mod tests { · 174 unmodified lines
497503 "toolchain_missing"
498504 );
499505 assert_eq!(kind(ALREADY_RUNNING), "build_busy");
500− assert_eq!(kind("Root document not found: main.tex"), "root_document_missing");
506+ assert_eq!(kind(&missing_root("main.tex")), "root_document_missing");
501507 assert_eq!(kind("The project changed before its build could start."), "project_changed");
502508 assert_eq!(kind("latexmk exited unexpectedly: signal"), "build_error");
503509 }
src-tauri/src/latex/compile_root.rs+41−5
@@ -42,9 +42,10 @@ pub(super) fn compiled_document(root: &Path, manifest: &ProjectManifest) -> Resu · 41 unmodified lines
4242 default_root_document(manifest).map(|document| document.path.clone())
4343 }
4444
45−/// Overleaf's compile rule: the file open in the editor wins when it is a
46−/// compilable root itself (\documentclass) or names one via `% !TEX root`.
47−/// The winner is written back as the manifest default before latexmk runs, so
45+/// Overleaf's compile rule, unless the writer chose the main document (Set as
46+/// main document, `project::main_document_chosen`), which then always builds:
47+/// the file open in the editor wins when it is a compilable root itself
48+/// (\documentclass) or names one via `% !TEX root`. The winner is written back as the manifest default before latexmk runs, so
4849 /// a chapter opened next still compiles this document, and everything that
4950 /// resolves the default root (PDF preview, SyncTeX, clean) agrees on what was
5051 /// built. A standalone figure or subfiles chapter is the exception: it
@@ -53,11 +54,11 @@ pub(super) fn compiled_document(root: &Path, manifest: &ProjectManifest) -> Resu · 2 unmodified lines
5354 pub(super) fn choose_compile_root(root: &Path, open_document: Option<&str>) -> Result<(), String> {
5455 match open_document.and_then(|open| project::resolve_compile_root(root, open)) {
5556 Some(target) if target.one_off => set_one_off_root(root, Some(&target.path)),
56− Some(target) => {
57+ Some(target) if !project::main_document_chosen(root) => {
5758 set_one_off_root(root, None);
5859 project::set_compile_root(root, &target.path)?;
5960 }
60− None => set_one_off_root(root, None),
61+ _ => set_one_off_root(root, None),
6162 }
6263 Ok(())
6364 }
@@ -107,4 +108,39 @@ mod tests { · 43 unmodified lines
107108 }
108109 assert_eq!(project::read_manifest(&root).unwrap().root_documents.len(), 1);
109110 }
111+
112+ /// The captain's project: main.tex and main_v2.tex both declare a class.
113+ #[test]
114+ fn a_main_document_set_by_the_writer_builds_whichever_root_is_open() {
115+ let root = TempDir::new("chosen-root");
116+ project::write_manifest(&root, &project::default_manifest("paper")).unwrap();
117+ let paper = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
118+ root.write("main.tex", paper);
119+ root.write("main_v2.tex", paper);
120+ root.write(
121+ "figures/plot.tex",
122+ "\\documentclass{standalone}\n\\begin{document}x\\end{document}\n",
123+ );
124+ let compiled =
125+ || compiled_document(&root, &project::read_manifest(&root).unwrap()).unwrap();
126+
127+ // Nothing chosen: the open root wins and becomes the default.
128+ choose_compile_root(&root, Some("main_v2.tex")).unwrap();
129+ assert_eq!(compiled(), "main_v2.tex");
130+
131+ project::choose_root_document(&root, "main.tex").unwrap();
132+ choose_compile_root(&root, Some("main_v2.tex")).unwrap();
133+ assert_eq!(compiled(), "main.tex", "Set as main document outlasts the open tab");
134+ // A standalone figure still builds on its own, and the choice stands.
135+ choose_compile_root(&root, Some("figures/plot.tex")).unwrap();
136+ assert_eq!(compiled(), "figures/plot.tex");
137+ choose_compile_root(&root, Some("main_v2.tex")).unwrap();
138+ assert_eq!(compiled(), "main.tex");
139+
140+ // A chosen main document that is gone no longer holds the build.
141+ std::fs::remove_file(root.join("main.tex")).unwrap();
142+ choose_compile_root(&root, Some("main_v2.tex")).unwrap();
143+ assert_eq!(compiled(), "main_v2.tex");
144+ assert!(!project::main_document_chosen(&root));
145+ }
110146 }
src-tauri/src/latex/synctex/tests.rs+1−0
@@ -439,6 +439,7 @@ fn subfolder_project(engine: &str) -> (TempDir, Vec<u8>) { · 438 unmodified lines
439439 path: "cv/sop/sop.tex".to_string(),
440440 name: "sop".to_string(),
441441 is_default: true,
442+ chosen: false,
442443 });
443444 project::write_manifest(&root, &manifest).unwrap();
444445 root.write(
src-tauri/src/lib.rs+1−0
@@ -421,6 +421,7 @@ pub fn run() { · 420 unmodified lines
421421 ipc::workspace::export_arxiv_package,
422422 ipc::workspace::refresh_project,
423423 ipc::workspace::update_project_manifest,
424+ ipc::workspace::set_root_document,
424425 ipc::workspace::set_project_deadline,
425426 ipc::workspace::set_project_spelling_words,
426427 ipc::workspace::set_project_grammar_ignores,
src-tauri/src/models.rs+4−0
@@ -12,6 +12,10 @@ pub struct RootDocument { · 11 unmodified lines
1212 pub path: String,
1313 pub name: String,
1414 pub is_default: bool,
15+ /// The writer chose this default with Set as main document: while it is on
16+ /// disk, the file open when building no longer re-targets the build.
17+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
18+ pub chosen: bool,
1519 }
1620
1721 fn default_pdf_engine() -> String {
src-tauri/src/overleaf.rs+2−0
@@ -46,6 +46,7 @@ mod files; · 45 unmodified lines
4646 mod link;
4747 mod projects;
4848 mod review;
49+mod root_document;
4950 mod sync;
5051 #[cfg(test)]
5152 mod test_support;
@@ -70,6 +71,7 @@ pub use review::{ · 18 unmodified lines
7071 resolve_thread, send_chat_message, threads, OverleafCommentAnchor, OverleafMessage,
7172 OverleafThread,
7273 };
74+pub use root_document::{record_root_document, reported_root_document};
7375 pub use sync::{
7476 classify_sync_error, preview, probe, sync, sync_relocations, OverleafPreview, OverleafProbe,
7577 OverleafSyncResult,
src-tauri/src/overleaf/link.rs+4−0
@@ -77,6 +77,10 @@ pub(super) struct SyncState { · 76 unmodified lines
7777 /// [`SyncState::access`]).
7878 #[serde(default)]
7979 pub permission: Option<String>,
80+ /// Overleaf's main document (its `rootDoc_id`, as a path) as the live
81+ /// channel last reported it; see `root_document::record_root_document`.
82+ #[serde(default, skip_serializing_if = "Option::is_none")]
83+ pub root_document: Option<String>,
8084 /// Relative path (forward slashes) → sha256 hex of the content at the
8185 /// last successful sync.
8286 #[serde(default)]
src-tauri/src/overleaf/root_document.rs+153−0
@@ -0,0 +1,153 @@
1+//! Overleaf's main document (`rootDoc_id`), kept for the one case where this
2+//! copy needs a pick of its own: a linked project whose root went missing
3+//! (`project::open`). The document builds compile is otherwise the writer's
4+//! choice (`Set as main document`), and Overleaf's never switches it.
5+
6+use super::link::{load_state, save_state};
7+use std::path::Path;
8+
9+/// `None` when `root` is not linked to Overleaf; else Overleaf's main document
10+/// as the live channel last reported it.
11+pub fn reported_root_document(root: &Path) -> Option<Option<String>> {
12+ Some(load_state(root).ok()?.root_document)
13+}
14+
15+/// Record Overleaf's main document `path` as the live channel reports it (on
16+/// joining and with each tree change), whether or not the sync has brought the
17+/// file here yet. A main document that is not a `.tex` file is never compiled,
18+/// so it records none.
19+pub fn record_root_document(root: &Path, path: Option<&str>) -> Result<(), String> {
20+ let path = path
21+ .map(|path| path.trim().replace('\\', "/"))
22+ .filter(|path| path.to_ascii_lowercase().ends_with(".tex"));
23+ let mut state = load_state(root)?;
24+ if state.root_document == path {
25+ return Ok(());
26+ }
27+ state.root_document = path;
28+ save_state(root, &state)
29+}
30+
31+#[cfg(test)]
32+mod tests {
33+ use super::*;
34+ use crate::overleaf::sync::sync;
35+ use crate::overleaf::test_support::*;
36+ use crate::project;
37+ use std::collections::BTreeSet;
38+
39+ const PAPER: &[u8] = b"\\documentclass{article}\n\\begin{document}\nv2\n\\end{document}\n";
40+
41+ fn roots(root: &Path) -> Vec<(String, bool)> {
42+ let manifest = project::read_manifest(root).unwrap();
43+ (manifest.root_documents.into_iter())
44+ .map(|document| (document.path, document.is_default))
45+ .collect()
46+ }
47+
48+ fn default_root(root: &Path) -> String {
49+ roots(root).into_iter().find(|(_, is_default)| *is_default).unwrap().0
50+ }
51+
52+ #[test]
53+ fn overleafs_main_document_never_switches_a_root_that_is_still_here() {
54+ let both: Files = &[("main.tex", PAPER), ("main_v2.tex", PAPER)];
55+ let root = linked_root(both, both);
56+ project::open(&root).unwrap();
57+ assert_eq!(default_root(&root), "main.tex");
58+
59+ record_root_document(&root, Some("main_v2.tex")).unwrap();
60+ assert_eq!(reported_root_document(&root), Some(Some("main_v2.tex".into())));
61+ project::open(&root).unwrap();
62+ assert_eq!(default_root(&root), "main.tex", "the writer decides what builds compile");
63+
64+ // A main document that is not a .tex file is never compiled.
65+ record_root_document(&root, Some("notes.md")).unwrap();
66+ assert_eq!(reported_root_document(&root), Some(None));
67+ }
68+
69+ #[test]
70+ fn a_main_document_not_downloaded_yet_is_recorded() {
71+ let root = linked_root(&[("main.tex", PAPER)], &[("main.tex", PAPER)]);
72+ project::open(&root).unwrap();
73+ record_root_document(&root, Some("main_v2.tex")).unwrap();
74+ assert_eq!(reported_root_document(&root), Some(Some("main_v2.tex".into())));
75+ assert_eq!(roots(&root), [("main.tex".to_string(), true)]);
76+ }
77+
78+ /// The captain's report: main.tex renamed to main_v2.tex on Overleaf. The
79+ /// sync deletes main.tex here, and the manifest kept naming it, so the next
80+ /// open restored it into the editor and read a file that was gone: "No
81+ /// such file or directory (os error 2)".
82+ #[test]
83+ fn a_root_renamed_on_overleaf_is_replaced_when_the_project_opens_again() {
84+ let server = Mock::project(&[("main_v2.tex", PAPER)]).serve();
85+ let (config, root) = linked(&server, &[("main.tex", PAPER)], &[("main.tex", PAPER)]);
86+ project::open(&root).unwrap();
87+ assert_eq!(default_root(&root), "main.tex");
88+
89+ let result = sync(&config, &root, &BTreeSet::new(), None).unwrap();
90+ assert_eq!(
91+ (result.deleted_local, result.pulled),
92+ (vec!["main.tex".into()], vec!["main_v2.tex".into()])
93+ );
94+
95+ let snapshot = project::open(&root).unwrap();
96+ let roots: Vec<_> = (snapshot.manifest.root_documents.iter())
97+ .map(|document| (document.path.as_str(), document.is_default))
98+ .collect();
99+ assert_eq!(roots, [("main_v2.tex", true)], "the missing main.tex is dropped");
100+ assert!(project::read_file(&root, "main_v2.tex").is_ok());
101+ }
102+
103+ /// Detection would pick paper.tex (a conventional name); the main document
104+ /// Overleaf named last comes first.
105+ #[test]
106+ fn a_missing_root_falls_back_to_overleafs_main_document_before_detection() {
107+ let files: Files = &[("main.tex", PAPER), ("paper.tex", PAPER), ("main_v2.tex", PAPER)];
108+ let root = linked_root(files, files);
109+ project::open(&root).unwrap();
110+ record_root_document(&root, Some("main_v2.tex")).unwrap();
111+ assert_eq!(default_root(&root), "main.tex");
112+
113+ std::fs::remove_file(root.join("main.tex")).unwrap();
114+ project::open(&root).unwrap();
115+ assert_eq!(default_root(&root), "main_v2.tex");
116+ }
117+
118+ /// Opened mid-sync, before Overleaf's main document arrived, the project is
119+ /// left with no root; the next open, once it is here, fills the list.
120+ #[test]
121+ fn an_empty_root_list_is_filled_once_overleafs_main_document_arrives() {
122+ let root = linked_root(&[("main.tex", PAPER)], &[("main.tex", PAPER)]);
123+ project::open(&root).unwrap();
124+ record_root_document(&root, Some("main_v2.tex")).unwrap();
125+ std::fs::remove_file(root.join("main.tex")).unwrap();
126+ project::open(&root).unwrap();
127+ assert_eq!(roots(&root), []);
128+
129+ // Detection alone would pick paper.tex, a conventional name.
130+ std::fs::write(root.join("paper.tex"), PAPER).unwrap();
131+ std::fs::write(root.join("main_v2.tex"), PAPER).unwrap();
132+ project::open(&root).unwrap();
133+ assert_eq!(roots(&root), [("main_v2.tex".to_string(), true)]);
134+ }
135+
136+ /// A root that is not the default and went with a sync is dropped too, so
137+ /// a restore cannot open it from the manifest.
138+ #[test]
139+ fn a_missing_root_that_is_not_the_default_is_dropped() {
140+ let both: Files = &[("main.tex", PAPER), ("main_v2.tex", PAPER)];
141+ let root = linked_root(both, both);
142+ project::open(&root).unwrap();
143+ project::set_compile_root(&root, "main_v2.tex").unwrap();
144+ assert_eq!(
145+ roots(&root),
146+ [("main.tex".to_string(), false), ("main_v2.tex".to_string(), true)]
147+ );
148+
149+ std::fs::remove_file(root.join("main.tex")).unwrap();
150+ project::open(&root).unwrap();
151+ assert_eq!(roots(&root), [("main_v2.tex".to_string(), true)]);
152+ }
153+}
src-tauri/src/overleaf_rt/client.rs+6−24
@@ -3,7 +3,7 @@ · 2 unmodified lines
33
44 use super::codec::*;
55 use super::events::*;
6−use super::tree::{parse_project, NodeKind, Tree};
6+use super::tree::{parse_project, NodeKind, ProjectTree, Tree};
77 use super::{NOT_CONNECTED, SESSION_EXPIRED, USER_AGENT};
88 use crate::util::url_encode;
99 use futures_util::sink::{Sink, SinkExt};
@@ -201,9 +201,9 @@ impl Shared { · 191 unmodified lines
201201 fn edit_tree(&self, edit: impl FnOnce(&mut Tree) -> bool) {
202202 let mut tree = lock(&self.tree);
203203 if edit(&mut tree) {
204− let (docs, entities) = (tree.docs(), tree.entities());
204+ let changed = tree.changed();
205205 drop(tree);
206− self.emit(RealtimeEvent::TreeChanged { docs, entities });
206+ self.emit(changed);
207207 }
208208 }
209209 }
@@ -410,6 +410,8 @@ fn handle_event(shared: &Arc<Shared>, data: &str) -> Result<(), String> { · 200 unmodified lines
410410 shared.edit_tree(|tree| tree.move_to(id, folder));
411411 }
412412 }
413+ // Someone chose another main document in Overleaf's settings.
414+ "rootDocUpdated" => shared.edit_tree(|tree| tree.set_root_doc(arg(0))),
413415 "removeEntity" => {
414416 if let Some(id) = arg(0) {
415417 shared.edit_tree(|tree| tree.remove(id));
@@ -499,27 +501,6 @@ pub struct RealtimeClient { · 83 unmodified lines
499501 project: ProjectTree,
500502 }
501503
502−/// What `joinProject` told us about the project.
503−#[derive(Debug, Clone, Serialize)]
504−#[serde(rename_all = "camelCase")]
505−pub struct ProjectTree {
506− pub root_folder_id: String,
507− pub docs: Vec<DocEntry>,
508− /// Everything in the project, so a file can be acted on by id.
509− pub entities: Vec<EntityEntry>,
510− /// Our own Overleaf account id, echoed back so the app can name itself in
511− /// the per-account settings Overleaf stores.
512− pub user_id: Option<String>,
513− /// What this account may do to the project. The connect answer carries
514− /// it: without it the app read `undefined` and overwrote what the
515− /// projectJoined event had established, so a reviewer's first keystroke
516− /// went out as a plain edit, the server refused it, and live editing died.
517− pub permission: Permission,
518− /// Whether edits should be recorded as suggestions rather than applied
519− /// outright, for this account.
520− pub track_changes: bool,
521−}
522−
523504 // Hand-written because `Shared` holds the app's event callback; `Debug` is what
524505 // lets callers `expect_err` on `connect`.
525506 impl std::fmt::Debug for RealtimeClient {
@@ -630,6 +611,7 @@ impl RealtimeClient { · 104 unmodified lines
630611 root_folder_id: tree.root.clone(),
631612 docs: tree.docs(),
632613 entities: tree.entities(),
614+ root_document: tree.root_document(),
633615 user_id: config.user_id.clone(),
634616 permission,
635617 track_changes: track_changes_for(track_changes.as_ref(), config.user_id.as_deref()),
src-tauri/src/overleaf_rt/events.rs+2−0
@@ -62,6 +62,8 @@ pub enum RealtimeEvent { · 61 unmodified lines
6262 TreeChanged {
6363 docs: Vec<DocEntry>,
6464 entities: Vec<EntityEntry>,
65+ /// The main document Overleaf compiles, as a path (see `ProjectTree`).
66+ root_document: Option<String>,
6567 },
6668 /// Someone in the project moved, or appeared for the first time.
6769 ///
src-tauri/src/overleaf_rt/tree.rs+94−2
@@ -6,7 +6,8 @@ · 5 unmodified lines
66 //! path. So the only way to keep a path→document map current without
77 //! re-reading the project is to hold the tree itself and resolve paths from it.
88
9−use super::events::{DocEntry, EntityEntry, Permission};
9+use super::events::{DocEntry, EntityEntry, Permission, RealtimeEvent};
10+use serde::Serialize;
1011 use serde_json::Value;
1112 use std::collections::HashMap;
1213
@@ -36,6 +37,33 @@ struct TreeNode { · 23 unmodified lines
3637 pub(super) struct Tree {
3738 nodes: HashMap<String, TreeNode>,
3839 pub root: String,
40+ /// The id of the project's main document, which `joinProject` names as
41+ /// `rootDoc_id` and `rootDocUpdated` changes.
42+ root_doc: Option<String>,
43+}
44+
45+/// What `joinProject` told us about the project.
46+#[derive(Debug, Clone, Serialize)]
47+#[serde(rename_all = "camelCase")]
48+pub struct ProjectTree {
49+ pub root_folder_id: String,
50+ pub docs: Vec<DocEntry>,
51+ /// Everything in the project, so a file can be acted on by id.
52+ pub entities: Vec<EntityEntry>,
53+ /// The project's main document (`rootDoc_id`), the file Overleaf compiles,
54+ /// as a path; `None` when it names no document in the tree.
55+ pub root_document: Option<String>,
56+ /// Our own Overleaf account id, echoed back so the app can name itself in
57+ /// the per-account settings Overleaf stores.
58+ pub user_id: Option<String>,
59+ /// What this account may do to the project. The connect answer carries
60+ /// it: without it the app read `undefined` and overwrote what the
61+ /// projectJoined event had established, so a reviewer's first keystroke
62+ /// went out as a plain edit, the server refused it, and live editing died.
63+ pub permission: Permission,
64+ /// Whether edits should be recorded as suggestions rather than applied
65+ /// outright, for this account.
66+ pub track_changes: bool,
3967 }
4068
4169 impl Tree {
@@ -43,6 +71,7 @@ impl Tree { · 1 unmodified line
4371 let mut tree = Tree {
4472 nodes: HashMap::new(),
4573 root: root.get("_id").and_then(Value::as_str).unwrap_or_default().to_string(),
74+ root_doc: None,
4675 };
4776 tree.absorb(root, None, 0);
4877 tree
@@ -126,6 +155,25 @@ impl Tree { · 77 unmodified lines
126155 entities.map(|entity| DocEntry { id: entity.id, path: entity.path }).collect()
127156 }
128157
158+ /// The main document's path, while it is a document in the tree.
159+ pub fn root_document(&self) -> Option<String> {
160+ let id = self.root_doc.as_deref()?;
161+ (self.nodes.get(id)?.kind == NodeKind::Doc).then(|| self.path_of(id)).flatten()
162+ }
163+
164+ pub fn set_root_doc(&mut self, id: Option<&str>) -> bool {
165+ let changed = self.root_doc.as_deref() != id;
166+ self.root_doc = id.map(str::to_string);
167+ changed
168+ }
169+
170+ /// The tree as the app is told it changed: every document and entity, and
171+ /// the main document, which a rename or move of it changes too.
172+ pub fn changed(&self) -> RealtimeEvent {
173+ let (docs, entities) = (self.docs(), self.entities());
174+ RealtimeEvent::TreeChanged { docs, entities, root_document: self.root_document() }
175+ }
176+
129177 pub fn rename(&mut self, id: &str, name: &str) -> bool {
130178 self.nodes.get_mut(id).map(|node| node.name = name.to_string()).is_some()
131179 }
@@ -189,5 +237,49 @@ pub(super) fn parse_project(body: &[Value]) -> Result<(Tree, Permission, Option< · 57 unmodified lines
189237 return Err("Overleaf's root folder has no id.".to_string());
190238 }
191239 let track_changes = project.get("trackChangesState").cloned();
192− Ok((Tree::from_root(root), permission, track_changes))
240+ let mut tree = Tree::from_root(root);
241+ tree.set_root_doc(project.get("rootDoc_id").and_then(Value::as_str));
242+ Ok((tree, permission, track_changes))
243+}
244+
245+#[cfg(test)]
246+mod tests {
247+ use super::super::tests::project_tree;
248+ use super::*;
249+ use serde_json::json;
250+
251+ fn joined(root_doc: Value) -> Tree {
252+ let mut project = project_tree();
253+ project["rootDoc_id"] = root_doc;
254+ parse_project(&[project, json!("owner")]).unwrap().0
255+ }
256+
257+ /// The main document is named by id, so a rename or move in the browser
258+ /// changes its path without any event that mentions it.
259+ #[test]
260+ fn the_main_document_is_followed_by_id_through_renames_and_settings_changes() {
261+ let mut tree = joined(json!("doc-1"));
262+ assert_eq!(tree.root_document().as_deref(), Some("main.tex"));
263+
264+ assert!(tree.rename("doc-1", "main_v2.tex"));
265+ let RealtimeEvent::TreeChanged { root_document, .. } = tree.changed() else { panic!() };
266+ assert_eq!(root_document.as_deref(), Some("main_v2.tex"));
267+
268+ // `rootDocUpdated`: someone chose another main document.
269+ assert!(tree.set_root_doc(Some("doc-2")));
270+ assert!(!tree.set_root_doc(Some("doc-2")), "the same choice again changes nothing");
271+ assert_eq!(tree.root_document().as_deref(), Some("sections/intro.tex"));
272+
273+ // Deleted, it names nothing rather than a path that is gone.
274+ assert!(tree.remove("folder-1"));
275+ assert_eq!(tree.root_document(), None);
276+ }
277+
278+ #[test]
279+ fn a_main_document_that_is_no_document_names_nothing() {
280+ // A binary file, an id the tree never held, or none at all.
281+ for root_doc in [json!("file-1"), json!("doc-unknown"), Value::Null] {
282+ assert_eq!(joined(root_doc.clone()).root_document(), None, "{root_doc}");
283+ }
284+ }
193285 }
src-tauri/src/project.rs+3−2
@@ -88,8 +88,9 @@ pub use linked_bibliography::{ · 87 unmodified lines
8888 };
8989 pub(crate) use manifest::ensure_ignore_line;
9090 pub use manifest::{
91− classify_open_error, has_latexmkrc, latexmk_engine_arg, open, read_manifest,
92− resolve_compile_root, set_compile_root, tex_program_engine,
91+ choose_root_document, classify_open_error, has_latexmkrc, latexmk_engine_arg,
92+ main_document_chosen, open, read_manifest, resolve_compile_root, set_compile_root,
93+ tex_program_engine,
9394 };
9495 pub use manifest_settings::{
9596 set_deadline, set_grammar_ignores, set_spelling_words, set_submission_items,
src-tauri/src/project/create.rs+1−0
@@ -194,6 +194,7 @@ fn default_manifest_with_venue(name: &str, venue: Venue) -> ProjectManifest { · 193 unmodified lines
194194 path: "main.tex".to_string(),
195195 name: "Main paper".to_string(),
196196 is_default: true,
197+ chosen: false,
197198 }],
198199 primary_bibliography: "references.bib".to_string(),
199200 trusted: false,
src-tauri/src/project/manifest.rs+56−1
@@ -52,7 +52,8 @@ pub fn open(root: &Path) -> Result<ProjectSnapshot, String> { · 51 unmodified lines
5252 let adopted = !root.join(MANIFEST_PATH).exists();
5353 wide_event::record("adopted", adopted);
5454 let mut manifest = if adopted { adopt_folder(&root)? } else { read_manifest(&root)? };
55− if apply_tex_magic_comments(&root, &mut manifest)? {
55+ let replaced = replace_missing_root(&root, &mut manifest);
56+ if apply_tex_magic_comments(&root, &mut manifest)? || replaced {
5657 write_manifest(&root, &manifest)?;
5758 }
5859 if manifest.schema_version < MANIFEST_SCHEMA_VERSION {
@@ -203,6 +204,7 @@ fn make_default_root(manifest: &mut ProjectManifest, relative: &str, fallback_na · 144 unmodified lines
203204 path: relative.to_string(),
204205 name: document_name(relative, fallback_name),
205206 is_default: false,
207+ chosen: false,
206208 });
207209 changed = true;
208210 }
@@ -210,6 +212,35 @@ fn make_default_root(manifest: &mut ProjectManifest, relative: &str, fallback_na · 1 unmodified line
210212 let is_default = document.path == relative;
211213 changed |= document.is_default != is_default;
212214 document.is_default = is_default;
215+ document.chosen &= is_default;
216+ }
217+ changed
218+}
219+
220+/// In a project linked to Overleaf, a sync applies Overleaf's rename or delete
221+/// of a root document (`main.tex` → `main_v2.tex`), which a local delete is
222+/// refused. Still named here, it was restored into the editor and read as a
223+/// file that is gone ("No such file or directory"). Overleaf's copy decides
224+/// what exists, so the roots that are gone are dropped, and a default that went
225+/// with them, or a list left empty, is filled: by Overleaf's main document as
226+/// the live channel last reported it, then by what a folder opened for the
227+/// first time would detect.
228+/// A local folder keeps an absent root: mid-checkout or on another branch it
229+/// is still the writer's setting, and the build says which file is missing.
230+fn replace_missing_root(root: &Path, manifest: &mut ProjectManifest) -> bool {
231+ let exists = |relative: &str| safe_path(root, relative).is_ok_and(|path| path.is_file());
232+ let Some(reported) = crate::overleaf::reported_root_document(root) else { return false };
233+ let default_missing =
234+ default_root_document(manifest).is_some_and(|document| !exists(&document.path));
235+ let listed = manifest.root_documents.len();
236+ manifest.root_documents.retain(|document| exists(&document.path));
237+ let mut changed = manifest.root_documents.len() != listed;
238+ if default_missing || manifest.root_documents.is_empty() {
239+ if let Some(replacement) =
240+ reported.filter(|path| exists(path)).or_else(|| detect_root_document(root))
241+ {
242+ changed |= make_default_root(manifest, &replacement, "Root");
243+ }
213244 }
214245 changed
215246 }
@@ -416,6 +447,30 @@ pub fn set_compile_root(root: &Path, path: &str) -> Result<ProjectManifest, Stri · 200 unmodified lines
416447 Ok(manifest)
417448 }
418449
450+/// Set as main document: `path` becomes the default, and stays it while it is
451+/// on disk whichever file is open when building (see `main_document_chosen`).
452+pub fn choose_root_document(root: &Path, path: &str) -> Result<ProjectManifest, String> {
453+ let relative = path.trim().replace('\\', "/");
454+ let mut manifest = read_manifest(root)?;
455+ make_default_root(&mut manifest, &relative, &relative);
456+ for document in &mut manifest.root_documents {
457+ document.chosen = document.is_default;
458+ }
459+ write_manifest(root, &manifest)?;
460+ Ok(manifest)
461+}
462+
463+/// Whether the default is one the writer chose (Set as main document) and is
464+/// still on disk; then only a one-off build compiles anything else.
465+pub fn main_document_chosen(root: &Path) -> bool {
466+ read_manifest(root)
467+ .ok()
468+ .and_then(|manifest| {
469+ default_root_document(&manifest).filter(|document| document.chosen).cloned()
470+ })
471+ .is_some_and(|document| safe_path(root, &document.path).is_ok_and(|path| path.is_file()))
472+}
473+
419474 /// Pick the best root `.tex` for foreign / Overleaf-style trees.
420475 fn detect_root_document(root: &Path) -> Option<String> {
421476 let tex_files = || {
src-tauri/src/project/templates.rs+1−0
@@ -268,6 +268,7 @@ pub fn instantiate( · 267 unmodified lines
268268 path: file.clone(),
269269 name: file.clone(),
270270 is_default: *file == item.root,
271+ chosen: false,
271272 })
272273 .collect();
273274 manifest::write_manifest(&root, &manifest)?;
src-tauri/src/project/tree.rs+6−1
@@ -407,7 +407,12 @@ const NOT_EDITABLE: &str = · 406 unmodified lines
407407 pub fn read_file(root: &Path, relative: &str) -> Result<String, String> {
408408 let path = safe_path(root, relative)?;
409409 // One read serves classification and content.
410− let metadata = fs::symlink_metadata(&path).map_err(err)?;
410+ // A tab, a restored workspace or a stale root can name a file a sync or
411+ // Finder removed; "No such file or directory (os error 2)" said neither which.
412+ let metadata = fs::symlink_metadata(&path).map_err(|error| match error.kind() {
413+ std::io::ErrorKind::NotFound => format!("{relative} is not in the project folder."),
414+ _ => err(error),
415+ })?;
411416 if !metadata.file_type().is_file() {
412417 return Err(NOT_EDITABLE.to_string());
413418 }
src/App.tsx+1−1
@@ -576,7 +576,7 @@ function App({ surfaces }: { surfaces: AppSurfaces }) { · 575 unmodified lines
576576 useLayoutEffect(() => {
577577 annotationRounds.attach({
578578 store: editorComments, projectRootRef, activeFileRef, sourceRef, openProjectFile: openFile,
579− author: { id: editorCommentAuthorId, name: authorName }, overleafLinked: overleafLink !== null,
579+ author: { id: editorCommentAuthorId, name: authorName },
580580 openPanel: () => tools.open("comments"),
581581 openHistoryEntry: (historyEntry) => tools.open("history", { historyEntry }),
582582 save,
src/app-types.ts+2−0
@@ -10,6 +10,8 @@ type RootDocument = { · 9 unmodified lines
1010 path: string;
1111 name: string;
1212 isDefault: boolean;
13+ /** The writer chose this default (Set as main document): builds compile it whichever file is open. */
14+ chosen?: boolean;
1315 };
1416
1517 export type ProjectVenue =
src/app/adopt-root-document.ts+3−1
@@ -9,7 +9,9 @@ import { baseName } from "../lib/base-name"; · 8 unmodified lines
99 export function adoptRootDocument(project: ProjectSnapshot, rootDocument: string): ProjectSnapshot {
1010 const documents = project.manifest.rootDocuments;
1111 if (documents.some((document) => document.isDefault && document.path === rootDocument)) return project;
12− const rootDocuments = documents.map((document) => ({ ...document, isDefault: document.path === rootDocument }));
12+ const rootDocuments: typeof documents = documents.map((document) => ({
13+ ...document, isDefault: document.path === rootDocument, chosen: document.chosen && document.path === rootDocument,
14+ }));
1315 if (!documents.some((document) => document.path === rootDocument)) {
1416 const stem = baseName(rootDocument).replace(/\.tex$/i, "");
1517 rootDocuments.push({ path: rootDocument, name: stem || rootDocument, isDefault: true });
src/app/annotation-rounds.test.ts+38−4
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; · 2 unmodified lines
33 import { reviseAnnotationWithAgent } from "../agent/agent-annotations";
44 import { annotateSelection } from "../canvas/annotate-selection";
55 import { createEditorComment, type AnnotationIntent, type AnnotationRound, type EditorComment } from "../editor/comments/editor-comment-data";
6+import { sourceRangeForPdfText } from "../editor/comments/pdf-source-anchor";
67 import { AnnotationRounds, type AnnotationRoundsDeps } from "./annotation-rounds";
78
89 vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
@@ -39,12 +40,13 @@ function roundsWith(annotations: EditorComment[]) { · 30 unmodified lines
3940 state.current = { ...state.current, annotations: [...state.current.annotations, created] };
4041 return created;
4142 },
42− setActiveId: vi.fn(), openComment: vi.fn(), resolve: vi.fn(), reply: vi.fn(),
43+ destination: () => "annotation", setActiveId: vi.fn(), openComment: vi.fn(), resolve: vi.fn(), reply: vi.fn(),
4344 };
4445 const rounds = new AnnotationRounds();
46+ const sourceRef = { current: SOURCE };
4547 rounds.attach({
46− store, projectRootRef: { current: "/paper" }, activeFileRef: { current: "main.tex" }, sourceRef: { current: SOURCE },
47− openProjectFile: vi.fn(), author: { id: "ada", name: "Ada" }, overleafLinked: false, openPanel: vi.fn(),
48+ store, projectRootRef: { current: "/paper" }, activeFileRef: { current: "main.tex" }, sourceRef,
49+ openProjectFile: vi.fn(), author: { id: "ada", name: "Ada" }, openPanel: vi.fn(),
4850 openHistoryEntry: vi.fn(), save: vi.fn(async () => true), reloadAfterRestore: vi.fn(async () => {}),
4951 });
5052 /** The passages the agent was asked to revise, once every task has answered. */
@@ -53,7 +55,7 @@ function roundsWith(annotations: EditorComment[]) { · 2 unmodified lines
5355 return vi.mocked(reviseAnnotationWithAgent).mock.calls.map(([request]) => request.passage);
5456 };
5557 const proposed = () => state.current.annotations.filter((annotation) => annotation.revision?.state === "proposed").map((annotation) => annotation.quote);
56− return { rounds, store, asked, proposed };
58+ return { rounds, store, sourceRef, asked, proposed };
5759 }
5860
5961 beforeEach(() => {
@@ -63,6 +65,38 @@ beforeEach(() => { · 3 unmodified lines
6365 }));
6466 });
6567
68+describe("AnnotationRounds / PDF notes posted to Overleaf", () => {
69+ // Overleaf anchors a comment by offset in the live document, which a
70+ // collaborator may have changed since the passage was selected on the PDF.
71+ function overleafNote(live: string) {
72+ const { rounds, store, sourceRef } = roundsWith([]);
73+ const create = vi.fn(() => null);
74+ Object.assign(store, { destination: () => "overleaf", create });
75+ sourceRef.current = live;
76+ // As mapPdfSelection placed it when the PDF text was selected.
77+ const { from, to } = sourceRangeForPdfText(SOURCE, 2, passage(1))!;
78+ const mapped = { path: "main.tex", line: 2, from, to, source: SOURCE };
79+ const selection = { page: 1, rects: [], text: passage(1), point: { x: 0, y: 0 } };
80+ const saved = rounds.createFromPdf(selection, mapped, "Cite this.", undefined, false);
81+ return { saved, create, from, quote: SOURCE.slice(from, to) };
82+ }
83+
84+ it("anchors the comment where the passage stands in the live text now", () => {
85+ const added = "A collaborator wrote this line above.\n";
86+ const { saved, create, from, quote } = overleafNote(added + SOURCE);
87+ expect(saved).toBe(true);
88+ const [comment] = create.mock.calls[0] as unknown as [EditorComment];
89+ expect(comment.from).toBe(added.length + from);
90+ expect(comment.quote).toBe(quote);
91+ });
92+
93+ it("posts nothing when the passage itself changed since the PDF was built", () => {
94+ const { saved, create } = overleafNote(SOURCE.replace("deliberately simple", "simple"));
95+ expect(saved).toBe(false);
96+ expect(create).not.toHaveBeenCalled();
97+ });
98+});
99+
66100 describe("AnnotationRounds / revise scope", () => {
67101 // The captain's case: two notes are open, the writer selects a third passage,
68102 // picks Shorten and saves with Save and revise now. Only that passage changes.
src/app/annotation-rounds.ts+17−8
@@ -24,7 +24,7 @@ import type { PdfTextSelection } from "../pdf/pdf-annotations"; · 23 unmodified lines
2424 import { notifyInfo } from "../telemetry/app-notify";
2525 import { showError } from "./notify";
2626 import { createAnnotationReviewBridge } from "../canvas/annotation-review-bridge";
27−import type { EditorComments } from "./use-editor-comments";
27+import type { CommentDestination, EditorComments } from "./use-editor-comments";
2828
2929 /** Text tasks the agent service runs at once is four; one stays free for a proofread. */
3030 const CONCURRENT_REVISIONS = 3;
@@ -76,15 +76,13 @@ const lineAt = (source: string, offset: number) => source.slice(0, offset).split · 45 unmodified lines
7676
7777 /** What the rounds read and do in the app, attached by App once it has them. */
7878 export type AnnotationRoundsDeps = {
79− store: Pick<EditorComments, "stateRef" | "commit" | "create" | "setActiveId" | "openComment" | "resolve" | "reply">;
79+ store: Pick<EditorComments, "stateRef" | "commit" | "create" | "destination" | "setActiveId" | "openComment" | "resolve" | "reply">;
8080 projectRootRef: Ref<string | null>;
8181 activeFileRef: Ref<string>;
8282 /** The active file's live text. */
8383 sourceRef: Ref<string>;
8484 openProjectFile: OpenProjectFile;
8585 author: { id: string; name: string };
86− /** A project linked to Overleaf keeps its notes as Overleaf threads, which have no place on the PDF. */
87− overleafLinked: boolean;
8886 openPanel: () => void;
8987 openHistoryEntry: (id: string) => void;
9088 /** Save pending edits before files are restored; false when that failed. */
@@ -550,8 +548,11 @@ export class AnnotationRounds { · 459 unmodified lines
550548 await deps.reloadAfterRestore();
551549 };
552550
553− /** Annotations can be made on the compiled PDF (not in a project linked to Overleaf). */
554− canAnnotatePdf = () => Boolean(this.deps && !this.deps.overleafLinked);
551+ /** Annotations can be made on the compiled PDF; in a project linked to Overleaf they become Overleaf comments. */
552+ canAnnotatePdf = () => Boolean(this.deps);
553+
554+ /** Where a note made on the PDF and placed in `path` goes (see `CommentDestination`). */
555+ pdfNoteDestination = (path: string): CommentDestination => this.deps?.store.destination(path) ?? "annotation";
555556
556557 /** Where text selected on the PDF was written: SyncTeX's line, then the words around it. */
557558 mapPdfSelection = async (selection: PdfTextSelection): Promise<MappedPdfSelection> => {
@@ -563,12 +564,20 @@ export class AnnotationRounds { · 5 unmodified lines
563564 return { path: target.path, line: source.slice(0, range.from).split("\n").length, from: range.from, to: range.to, source };
564565 };
565566
566− /** Save a note made on the PDF; false when its source no longer holds the span. Revises it alone at once with `revise`. */
567+ /**
568+ * Save a note made on the PDF; false when its source no longer holds the
569+ * span. Revises it alone at once with `revise`. An Overleaf comment is
570+ * anchored by offset in the live document, so it is placed again in the
571+ * text as it stands now, which collaborators may have changed meanwhile.
572+ */
567573 createFromPdf = (selection: PdfTextSelection, mapped: MappedPdfSelection, body: string, intent: AnnotationIntent | undefined, revise: boolean) => {
568574 const deps = this.deps;
569575 if (!deps) return false;
576+ const source = deps.store.destination(mapped.path) === "overleaf" ? deps.sourceRef.current : mapped.source;
577+ const range = source === mapped.source ? mapped : sourceRangeForPdfText(source, mapped.line, selection.text);
578+ if (!range || source.slice(range.from, range.to) !== mapped.source.slice(mapped.from, mapped.to)) return false;
570579 const comment = createEditorComment({
571− path: mapped.path, source: mapped.source, from: mapped.from, to: mapped.to, body,
580+ path: mapped.path, source, from: range.from, to: range.to, body,
572581 // eslint-disable-next-line lingui/no-unlocalized-strings -- stored sentinel; editorCommentAuthorDisplayName translates it
573582 authorId: deps.author.id, authorName: deps.author.name.trim() || "Anonymous",
574583 intent, pdf: { page: selection.page, rects: selection.rects, text: selection.text },
src/app/app-overleaf-drawer.tsx+2−1
@@ -8,7 +8,8 @@ import { · 7 unmodified lines
88 type Dispatch, type ReactNode, type RefObject, type SetStateAction,
99 } from "react";
1010 import type { EditorView } from "@codemirror/view";
11−import { OVERLEAF_COMMENT_PREFIX, type useOverleafWorkspace } from "./use-overleaf-workspace";
11+import { OVERLEAF_COMMENT_PREFIX } from "../overleaf/overleaf-editor-comments";
12+import type { useOverleafWorkspace } from "./use-overleaf-workspace";
1213 import type { OpenProjectFile, ViewRestoreRequest } from "../app-types";
1314 import { lazyChunk } from "../lib/lazy-chunk";
1415 import { hadSuggestions, loadMarginReviewLayer, rememberSuggestions, subscribeMarginReviewFiles } from "../overleaf/margin-review-files";
src/app/use-editor-comments.test.ts+30−0
@@ -130,3 +130,33 @@ it("resolves and reopens from the panel with a toast whose undo puts it back", a · 129 unmodified lines
130130 act(later);
131131 expect(view.result.current.comments[0]?.resolved).toBe(false);
132132 });
133+
134+// A note made on the PDF of a linked project is an Overleaf comment, which
135+// Overleaf takes only on the document the live channel holds.
136+it("sends a PDF note to Overleaf only for the live file, and keeps one for a file Overleaf lacks", () => {
137+ const destinationIn = (docPaths: Map<string, string>, realtime: { status: string; docId: string | null; liveFile: boolean }, linked = true) => {
138+ const overleaf = {
139+ overleafLink: linked ? { projectId: "p" } : null, overleafComments: { threads: [], anchors: new Map() },
140+ overleafCommentsRef: { current: { threads: [] } }, overleafDocPaths: docPaths, overleafRealtime: realtime,
141+ overleafEditorComments: [], setOverleafCollabOpen: vi.fn(), setOverleafCollabTab: vi.fn(),
142+ } as unknown as Parameters<typeof useEditorComments>[0]["overleaf"];
143+ const { result } = renderHook(() => useEditorComments({
144+ project: { root: "/project" } as unknown as ProjectSnapshot, projectRootRef: { current: "/project" },
145+ activeFileRef: { current: "main_v2.tex" }, openProjectFile: vi.fn(), overleaf, author: { id: "me", name: "Me" },
146+ openSources: () => new Map(), agentOptionsRef: { current: null },
147+ }));
148+ return result.current.destination;
149+ };
150+ const docs = new Map([["doc-v2", "main_v2.tex"], ["doc-intro", "intro.tex"]]);
151+ const live = destinationIn(docs, { status: "live", docId: "doc-v2", liveFile: true });
152+ expect(live("main_v2.tex")).toBe("overleaf");
153+ expect(live("intro.tex")).toBe("other-file");
154+ // Connected, with a tab Overleaf cannot edit live open (an image, a Paper): open the file, not reconnect.
155+ expect(destinationIn(docs, { status: "live", docId: null, liveFile: false })("main_v2.tex")).toBe("other-file");
156+ // A file Overleaf does not have stays an annotation here, as in the editor.
157+ expect(live("notes.tex")).toBe("annotation");
158+ // Disconnected, or connected before the channel named its documents: never quietly kept here.
159+ expect(destinationIn(docs, { status: "error", docId: null, liveFile: false })("main_v2.tex")).toBe("offline");
160+ expect(destinationIn(new Map(), { status: "connecting", docId: null, liveFile: false })("main_v2.tex")).toBe("offline");
161+ expect(destinationIn(docs, { status: "live", docId: "doc-v2", liveFile: true }, false)("main_v2.tex")).toBe("annotation");
162+});
src/app/use-editor-comments.ts+29−4
@@ -13,10 +13,19 @@ import { · 12 unmodified lines
1313 import { publishMarginReviewComments } from "../overleaf/margin-review-files";
1414 import { showError } from "./notify";
1515 import { notifyInfo } from "../telemetry/app-notify";
16−import { OVERLEAF_COMMENT_PREFIX, type useOverleafWorkspace } from "./use-overleaf-workspace";
16+import { OVERLEAF_COMMENT_PREFIX } from "../overleaf/overleaf-editor-comments";
17+import type { useOverleafWorkspace } from "./use-overleaf-workspace";
1718
1819 type Ref<T> = { readonly current: T };
1920
21+/**
22+ * Where a note on a file goes: an annotation of this project's, or a thread on
23+ * Overleaf. In a linked project it can go nowhere yet: Overleaf takes a
24+ * comment only on the document the live channel holds, which is the open file
25+ * ("other-file" while another is), and none while nothing is live ("offline").
26+ */
27+export type CommentDestination = "annotation" | "overleaf" | "other-file" | "offline";
28+
2029 /** The project's local annotations and the revision rounds that asked about them. */
2130 export type AnnotationsState = { annotations: EditorComment[]; rounds: AnnotationRound[] };
2231
@@ -188,8 +197,24 @@ export function useEditorComments({ · 165 unmodified lines
188197 * to the annotation as saved, or null when it became an Overleaf thread or
189198 * could not be made.
190199 */
200+ const docIdOf = useCallback((path: string) => (
201+ [...overleafDocPaths.entries()].find(([, docPath]) => docPath === path)?.[0] ?? null
202+ ), [overleafDocPaths]);
203+ /**
204+ * Where a note on `path` made on the PDF goes. Unlike `create`, a linked
205+ * project whose channel has not named its documents yet is "offline", not
206+ * an annotation: a note meant for Overleaf must not be kept here unseen.
207+ */
208+ const destination = useCallback((path: string): CommentDestination => {
209+ if (!overleafLink || !project) return "annotation";
210+ const docId = docIdOf(path);
211+ if (!docId && overleafDocPaths.size) return "annotation";
212+ if (overleafRealtime.liveFile && overleafRealtime.docId === docId) return "overleaf";
213+ return overleafRealtime.status === "live" ? "other-file" : "offline";
214+ }, [docIdOf, overleafDocPaths, overleafLink, overleafRealtime.docId, overleafRealtime.liveFile, overleafRealtime.status, project]);
215+
191216 const create = useCallback((comment: EditorComment): EditorComment | null => {
192− const docId = [...overleafDocPaths.entries()].find(([, path]) => path === comment.path)?.[0] ?? null;
217+ const docId = docIdOf(comment.path);
193218 if (!overleafLink || !docId || !project) {
194219 const annotation = { ...comment, number: nextAnnotationNumber(stateRef.current.annotations) };
195220 commit(withAnnotations((current) => [...current, annotation]));
@@ -204,7 +229,7 @@ export function useEditorComments({ · 8 unmodified lines
204229 .create({ projectRoot: project.root, docId, path: comment.path }, comment.from, comment.quote, comment.body)
205230 .catch((reason) => showError(toMessage(reason)));
206231 return null;
207− }, [overleafComments, overleafDocPaths, overleafLink, overleafRealtime.docId, overleafRealtime.liveFile, project, commit, t]);
232+ }, [docIdOf, overleafComments, overleafLink, overleafRealtime.docId, overleafRealtime.liveFile, project, commit, t]);
208233
209234 const openPanel = useCallback(() => {
210235 setPanelFocus(null);
@@ -290,7 +315,7 @@ export function useEditorComments({ · 79 unmodified lines
290315 }, [agentOptionsRef, openSources, overleafComments.anchors, overleafComments.threads, overleafDocPaths, overleafLink, project, projectRootRef]);
291316
292317 return {
293− comments, rounds: state.rounds, stateRef, commit, all, update, create, toggleResolved, resolve, reply, reset, load, openComment,
318+ comments, rounds: state.rounds, stateRef, commit, all, update, create, destination, toggleResolved, resolve, reply, reset, load, openComment,
294319 deleteComment, panelOpen, openPanel, openReply, closePanel, panelFocus, panelFocusId, setPanelFocus,
295320 activeId, setActiveId, focusRequest, focusHandled,
296321 };
src/app/use-overleaf-workspace.test.ts+2−1
@@ -4,7 +4,8 @@ import { invoke } from "@tauri-apps/api/core"; · 3 unmodified lines
44 import type { OverleafThread, ProjectSnapshot } from "../app-types";
55 import { createOverleafConnectionStore } from "../overleaf/overleaf-connection-store";
66 import { useOverleafRealtime } from "../overleaf/use-overleaf-realtime";
7−import { applyOverleafRemoteText, projectOverleafEditorComments, useOverleafWorkspace, type OverleafWorkspaceDeps } from "./use-overleaf-workspace";
7+import { projectOverleafEditorComments } from "../overleaf/overleaf-editor-comments";
8+import { applyOverleafRemoteText, useOverleafWorkspace, type OverleafWorkspaceDeps } from "./use-overleaf-workspace";
89
910 vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
1011 vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => undefined) }));
src/app/use-overleaf-workspace.ts+3−43
@@ -26,11 +26,11 @@ import { · 25 unmodified lines
2626 import { mentionsAny } from "../platform/ipc-error-phrases";
2727 import { useOverleafChat } from "../overleaf/use-overleaf-chat";
2828 import { useOverleafPresence, type PresenceUser } from "../overleaf/use-overleaf-presence";
29−import { useOverleafComments, type OverleafComments } from "../overleaf/use-overleaf-comments";
29+import { useOverleafComments } from "../overleaf/use-overleaf-comments";
3030 import { useOverleafTrackChanges } from "../overleaf/use-overleaf-track-changes";
31−import { collabPeopleHues, peopleHuesFromKey, peopleHuesKey, personColor, type PeopleHues } from "../overleaf/overleaf-collab-people";
31+import { collabPeopleHues, peopleHuesFromKey, peopleHuesKey } from "../overleaf/overleaf-collab-people";
32+import { projectOverleafEditorComments } from "../overleaf/overleaf-editor-comments";
3233 import { type PresenceCursor } from "../overleaf/overleaf-editor-extensions";
33−import type { EditorComment } from "../editor/comments/editor-comment-data";
3434 import { hasConflictMarkers } from "../history/conflict-markers";
3535 import { AUTO_COMMIT_MESSAGES } from "../history/version-messages";
3636 import type {
@@ -40,46 +40,6 @@ import type { · 3 unmodified lines
4040 import { SYNC_OPERATION } from "../telemetry/app-log-export";
4141 import { overleafSyncWarnings } from "./overleaf-sync-warnings";
4242
43−/** Marks a comment that lives on Overleaf rather than in this project; App's comment handlers route on it. */
44−export const OVERLEAF_COMMENT_PREFIX = "overleaf:";
45−
46−export function projectOverleafEditorComments(
47− threads: OverleafComments["threads"],
48− anchors: OverleafComments["anchors"],
49− docPaths: Map<string, string>,
50− liveDocId: string | null,
51− liveAnchors: Map<string, { threadId: string; position: number; quote: string }>,
52− hues: PeopleHues = new Map(),
53−): EditorComment[] {
54− // REST supplies ownership for every file; the joined document's live ranges
55− // are authoritative, including anchors removed since the REST snapshot.
56− const mergedAnchors = new Map([...anchors].filter(([, anchor]) => anchor.docId !== liveDocId));
57− if (liveDocId) {
58− for (const [id, anchor] of liveAnchors) mergedAnchors.set(id, { ...anchor, docId: liveDocId });
59− }
60− return threads.flatMap((thread) => {
61− const anchor = mergedAnchors.get(thread.id);
62− const path = anchor && docPaths.get(anchor.docId);
63− if (!anchor || !path) return [];
64− const [first, ...rest] = thread.messages;
65− return [{
66− id: `${OVERLEAF_COMMENT_PREFIX}${thread.id}`, number: 0, path,
67− from: anchor.position, to: anchor.position + anchor.quote.length, quote: anchor.quote, prefix: "", suffix: "",
68− body: first?.content ?? "", authorId: first?.authorEmail ?? "overleaf",
69− authorColor: first && personColor(hues, { name: first.authorName, email: first.authorEmail, mine: first.mine }),
70− // eslint-disable-next-line lingui/no-unlocalized-strings -- a name tagged with the product, the same in every locale
71− authorName: first ? `${first.authorName} (Overleaf)` : "Overleaf", resolved: thread.resolved,
72− replies: rest.map((message) => ({
73− id: message.id, authorId: message.authorEmail ?? "overleaf", authorName: message.authorName,
74− authorColor: personColor(hues, { name: message.authorName, email: message.authorEmail, mine: message.mine }),
75− body: message.content, createdAt: new Date(message.timestamp).toISOString(),
76− })),
77− createdAt: new Date(first?.timestamp ?? 0).toISOString(),
78− updatedAt: new Date(thread.messages.at(-1)?.timestamp ?? 0).toISOString(),
79− }];
80− });
81−}
82−
8343 type OverleafSyncOptions = {
8444 auto?: boolean;
8545 observedRemoteVersion?: number | null;
src/app/use-panel-portals.tsx+15−2
@@ -11,6 +11,9 @@ · 10 unmodified lines
1111 */
1212 import { Suspense, useMemo, type Dispatch, type SetStateAction } from "react";
1313 import { createPortal } from "react-dom";
14+import { i18n } from "@lingui/core";
15+import { msg } from "@lingui/core/macro";
16+import { invoke } from "@tauri-apps/api/core";
1417 import { SynaraLoadingSurface } from "../agent/synara-loading-surface";
1518 import { paperImportStageLabel } from "../papers/paper-import-progress";
1619 import type { NewEntryRequest, NewEntryType } from "../project/project-new-entries";
@@ -20,9 +23,10 @@ import type { TrellisController } from "../trellis/trellis-controller"; · 3 unmodified lines
2023 import { PanelErrorBoundary } from "../telemetry/panel-error-boundary";
2124 import { PanelActions } from "../trellis/trellis-panel-actions";
2225 import type { GitStatus, PaperSummary, ProjectSnapshot } from "../app-types";
26+import { toMessage } from "../app-utils";
2327 import { PaperLibrary, ProjectFileTree, TrellisAgentSurface } from "./app-lazy-panels";
2428 import { useStableHandlers } from "./effect-helpers";
25−import { showError } from "./notify";
29+import { showError, showNotice } from "./notify";
2630 import { OpeningOverlay } from "./opening-overlay";
2731 import type { OpenDocuments } from "./use-open-documents";
2832 import type { usePaperActions } from "./use-paper-actions";
@@ -55,7 +59,7 @@ export function usePanelPortals({ · 26 unmodified lines
5559 newEntry: { request: NewEntryRequest | null; requestNew: (type: NewEntryType) => void };
5660 gitFiles: GitStatus["files"];
5761 rootDocumentPath: string | null | undefined;
58− library: Pick<ReturnType<typeof useProjectLibrary>, "papers" | "paperLoad" | "retryPapers" | "citations" | "unusedSymbols">;
62+ library: Pick<ReturnType<typeof useProjectLibrary>, "papers" | "paperLoad" | "retryPapers" | "citations" | "unusedSymbols" | "refreshProject">;
5963 overleafSyncing: boolean;
6064 paperActions: ReturnType<typeof usePaperActions>;
6165 referenceImport: ReferenceImport;
@@ -116,6 +120,14 @@ export function usePanelPortals({ · 54 unmodified lines
116120 onImportAssets: tree.chooseAssets,
117121 onImportFiles: (targetDirectory: string) => void tree.chooseFiles(targetDirectory),
118122 onPasteImage: (targetDirectory: string) => void tree.importSystemClipboardImage(targetDirectory),
123+ onSetMainDocument: (path: string) => {
124+ if (!project) return;
125+ const projectRoot = project.root;
126+ void invoke("set_root_document", { projectRoot, path })
127+ .then(() => library.refreshProject())
128+ .then(() => showNotice(i18n._(msg`Build now compiles ${path}.`)))
129+ .catch((reason) => showError(toMessage(reason)));
130+ },
119131 });
120132 const paperLibraryHandlers = useStableHandlers({
121133 onReveal: tree.revealItem,
@@ -158,6 +170,7 @@ export function usePanelPortals({ · 36 unmodified lines
158170 activeFile={activeAsset || activePaper || (documents.tabsReady && !documents.tabs.includes(activeFile)) ? "" : activeFile}
159171 activeAssetPath={activeAsset?.path ?? ""}
160172 protectedPaths={protectedProjectPaths}
173+ mainDocument={project.manifest.rootDocuments.find((document) => document.isDefault && document.chosen)?.path ?? null}
161174 assetDropTarget={tree.assetDropTarget}
162175 assetImporting={tree.assetImporting}
163176 {...fileTreeHandlers}
src/app/workspace-restore.test.ts+11−0
@@ -49,4 +49,15 @@ describe("workspace restore", () => { · 48 unmodified lines
4949 activeTab: "sections/intro.tex",
5050 });
5151 });
52+
53+ // An Overleaf project whose main document is main_v2.tex, with an older
54+ // main.tex beside it, opened on main.tex whatever builds compiled.
55+ it("opens a project on the document builds compile, whatever it is called", () => {
56+ const both = {
57+ ...snapshot,
58+ files: [file("main.tex"), file("main_v2.tex")],
59+ manifest: { rootDocuments: [{ path: "main.tex", isDefault: false }, { path: "main_v2.tex", isDefault: true }] },
60+ } as unknown as ProjectSnapshot;
61+ expect(planWorkspaceRestore(both, [], null, null)).toMatchObject({ primaryFile: "main_v2.tex", tabs: ["main_v2.tex"] });
62+ });
5263 });
src/app/workspace-restore.ts+3−3
@@ -75,9 +75,9 @@ export function planWorkspaceRestore( · 74 unmodified lines
7575 const paperKeys = new Set(papers.filter((paper) => paper.hasBlog || paper.hasFullText)
7676 .map((paper) => paperTabKey(paper.arxivId)));
7777 const validTab = (path: string) => sourcePaths.has(path) || assetPaths.has(path) || paperKeys.has(path);
78− const rootDocument = rootDocuments.find((document) => document.path === "main.tex")
79− ?? rootDocuments.find((document) => document.isDefault)
80− ?? rootDocuments[0];
78+ // The document builds compile, whatever it is called: a project whose
79+ // Overleaf main document is main_v2.tex opens on that, not on a main.tex.
80+ const rootDocument = rootDocuments.find((document) => document.isDefault) ?? rootDocuments[0];
8181 // In place of a Paper that can no longer be read, open the project's root
8282 // document rather than whichever file happened to sit behind the Paper.
8383 const unreadablePaper = Boolean(layout?.activeTab && isPaperTabKey(layout.activeTab) && !paperKeys.has(layout.activeTab));
src/canvas/annotation-composer.tsx+6−4
@@ -56,6 +56,8 @@ export function AnnotationForm(props: { · 55 unmodified lines
5656 ready?: boolean;
5757 /** The agent can be asked now; otherwise only Save is offered. */
5858 canRevise: boolean;
59+ /** An Overleaf comment carries words alone: no intents, and Save says where it goes. */
60+ overleaf?: boolean;
5961 onBodyChange: (body: string) => void;
6062 onIntentChange: (intent: AnnotationIntent | undefined) => void;
6163 onCancel: () => void;
@@ -64,7 +66,7 @@ export function AnnotationForm(props: { · 2 unmodified lines
6466 const { t } = useLingui();
6567 const labels = useIntentLabels();
6668 const ready = props.ready !== false;
67− const saveable = ready && Boolean(props.body.trim() || props.intent);
69+ const saveable = ready && Boolean(props.body.trim() || (!props.overleaf && props.intent));
6870 return (
6971 <div
7072 className="annotation-form"
@@ -91,7 +93,7 @@ export function AnnotationForm(props: { · 20 unmodified lines
9193 value={props.body}
9294 onChange={(event) => props.onBodyChange(event.target.value)}
9395 />
94− <div className="annotation-intents" role="group" aria-label={t`What the agent should do`}>
96+ {!props.overleaf && <div className="annotation-intents" role="group" aria-label={t`What the agent should do`}>
9597 {ANNOTATION_INTENTS.map((intent) => (
9698 <button
9799 type="button"
@@ -103,11 +105,11 @@ export function AnnotationForm(props: { · 5 unmodified lines
103105 {labels[intent]}
104106 </button>
105107 ))}
106− </div>
108+ </div>}
107109 {props.error && <p role="alert" className="annotation-form-error">{props.error}</p>}
108110 <div className="annotation-form-actions">
109111 <Button size="compact" variant="tinted" disabled={!saveable} onClick={() => props.onSave(false)}>
110− {t`Save`} <kbd>{SAVE_KEYS}</kbd>
112+ {props.overleaf ? t`Comment on Overleaf` : t`Save`} <kbd>{SAVE_KEYS}</kbd>
111113 </Button>
112114 {props.canRevise && (
113115 <Button size="compact" variant="ghost" disabled={!saveable} onClick={() => props.onSave(true)}>
src/canvas/pdf-annotation-composer.test.tsx+78−0
@@ -0,0 +1,78 @@
1+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2+import { afterEach, describe, expect, it, vi } from "vitest";
3+import type { AnnotationRounds, MappedPdfSelection } from "../app/annotation-rounds";
4+import type { CommentDestination } from "../app/use-editor-comments";
5+import { PdfAnnotationComposer } from "./pdf-annotation-composer";
6+
7+// jsdom lays nothing out, so placed beside its passage the popup would stay
8+// hidden, and a hidden element has no accessible name to find it by.
9+vi.mock("../pdf/use-pdf-anchored-popup", () => ({ usePdfAnchoredPopup: () => {} }));
10+
11+afterEach(cleanup);
12+
13+const SELECTION = { page: 1, rects: [{ x: 0.1, y: 0.2, width: 0.3, height: 0.02 }], text: "attention weights", point: { x: 80, y: 160 } };
14+const MAPPED: MappedPdfSelection = { path: "main_v2.tex", line: 35, from: 10, to: 27, source: "" };
15+
16+/** The note on a PDF selection SyncTeX places in main_v2.tex, which goes to `destination`. */
17+function compose(destination: CommentDestination) {
18+ const now = { destination };
19+ const rounds = {
20+ mapPdfSelection: vi.fn(async () => MAPPED),
21+ pdfNoteDestination: vi.fn(() => now.destination),
22+ createFromPdf: vi.fn(() => true),
23+ } as unknown as AnnotationRounds;
24+ const onDone = vi.fn();
25+ render(<PdfAnnotationComposer selection={SELECTION} rounds={rounds} onDone={onDone} onDismiss={vi.fn()} />);
26+ return { rounds, onDone, now };
27+}
28+
29+const button = (name: string) => screen.findByRole("button", { name: new RegExp(name) });
30+
31+describe("a note made on the PDF of a project linked to Overleaf", () => {
32+ it("becomes an Overleaf comment on the span SyncTeX found: words alone, no agent revision", async () => {
33+ const { rounds, onDone } = compose("overleaf");
34+ await screen.findByText("PDF p.1 → main_v2.tex:35");
35+ expect(rounds.pdfNoteDestination).toHaveBeenCalledWith("main_v2.tex");
36+ expect(screen.queryByRole("group", { name: "What the agent should do" })).toBeNull();
37+ expect(screen.queryByRole("button", { name: /Save and revise now/ })).toBeNull();
38+
39+ fireEvent.change(screen.getByRole("textbox", { name: "Annotation" }), { target: { value: "Cite the source here." } });
40+ fireEvent.click(await button("Comment on Overleaf"));
41+ expect(rounds.createFromPdf).toHaveBeenCalledWith(SELECTION, MAPPED, "Cite the source here.", undefined, false);
42+ expect(onDone).toHaveBeenCalled();
43+ });
44+
45+ it("says why it cannot be posted when the passage is in a file that is not live, rather than failing quietly", async () => {
46+ for (const [destination, reason] of [
47+ ["other-file", /only on the file open in the editor, and this passage is in main_v2\.tex/],
48+ ["offline", /main_v2\.tex is not being edited live with Overleaf right now/],
49+ ] as const) {
50+ const { rounds } = compose(destination);
51+ expect((await screen.findByRole("alert")).textContent).toMatch(reason);
52+ fireEvent.change(screen.getByRole("textbox", { name: "Annotation" }), { target: { value: "Cite it." } });
53+ expect(await button("Comment on Overleaf")).toBeDisabled();
54+ expect(rounds.createFromPdf).not.toHaveBeenCalled();
55+ cleanup();
56+ }
57+ });
58+
59+ it("keeps what was typed when the live channel dropped before it was posted", async () => {
60+ const { rounds, onDone, now } = compose("overleaf");
61+ await screen.findByText("PDF p.1 → main_v2.tex:35");
62+ fireEvent.change(screen.getByRole("textbox", { name: "Annotation" }), { target: { value: "Cite the source here." } });
63+ // Nothing re-renders the composer when the channel goes; Save still reads enabled.
64+ now.destination = "offline";
65+ fireEvent.click(await button("Comment on Overleaf"));
66+ expect(rounds.createFromPdf).not.toHaveBeenCalled();
67+ expect(onDone).not.toHaveBeenCalled();
68+ expect((await screen.findByRole("alert")).textContent).toMatch(/main_v2\.tex is not being edited live with Overleaf right now/);
69+ expect(screen.getByRole("textbox", { name: "Annotation" })).toHaveValue("Cite the source here.");
70+ });
71+
72+ it("stays an annotation, with the agent's revision, in a project that is not linked", async () => {
73+ compose("annotation");
74+ await screen.findByText("PDF p.1 → main_v2.tex:35");
75+ expect(await button("Save and revise now")).toBeTruthy();
76+ expect(screen.getByRole("group", { name: "What the agent should do" })).toBeTruthy();
77+ });
78+});
src/canvas/pdf-annotation-composer.tsx+24−3
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; · 1 unmodified line
22 import { useLingui } from "@lingui/react/macro";
33 import { hostErrorMessage } from "../app-utils";
44 import type { AnnotationRounds, MappedPdfSelection } from "../app/annotation-rounds";
5+import type { CommentDestination } from "../app/use-editor-comments";
56 import { useExitPicture } from "../components/ui/exit-picture";
67 import { popupMotionClassName } from "../components/ui/popup-motion";
78 import { LoadingAnnouncement } from "../components/ui/loading-status";
@@ -37,6 +38,10 @@ export function PdfAnnotationComposer(props: { · 29 unmodified lines
3738 const [error, setError] = useState<string | null>(null);
3839 const [body, setBody] = useState(props.initial?.body ?? "");
3940 const [intent, setIntent] = useState<AnnotationIntent | undefined>(props.initial?.intent);
41+ // Where Save found the note had to go, when the live channel moved on after
42+ // it opened. Kept in state: the React Compiler memoizes the render-time read
43+ // below on `rounds` and `mapped`, so a bare re-render would not re-read it.
44+ const [destinationAtSave, setDestinationAtSave] = useState<CommentDestination | null>(null);
4045 const { rounds } = props;
4146
4247 useEffect(() => {
@@ -59,6 +64,16 @@ export function PdfAnnotationComposer(props: { · 16 unmodified lines
5964
6065 const name = mapped ? baseName(mapped.path) : null;
6166 const location = mapped ? t`PDF p.${selection.page} → ${name}:${mapped.line}` : t`PDF p.${selection.page} → …`;
67+ // In a project linked to Overleaf the note becomes an Overleaf comment on
68+ // the span SyncTeX found, which collaborators see; Overleaf takes one only
69+ // on the file the live channel holds, so a passage elsewhere says why not.
70+ const destination = destinationAtSave ?? (mapped ? rounds.pdfNoteDestination(mapped.path) : null);
71+ const toOverleaf = destination !== null && destination !== "annotation";
72+ const notLive = destination === "other-file"
73+ ? t`Overleaf takes a comment only on the file open in the editor, and this passage is in ${name}. Double-click it to open ${name}, then select it again.`
74+ : destination === "offline"
75+ ? t`This comment would go to Overleaf, but ${name} is not being edited live with Overleaf right now. Reconnect live editing (or turn on Live sync), then select it again.`
76+ : null;
6277 return (
6378 <div
6479 ref={popupRef}
@@ -73,14 +88,20 @@ export function PdfAnnotationComposer(props: { · 8 unmodified lines
7388 location={location}
7489 body={body}
7590 intent={intent}
76− error={error}
77− ready={Boolean(mapped)}
78− canRevise
91+ error={error ?? notLive}
92+ ready={Boolean(mapped) && !notLive}
93+ canRevise={!toOverleaf}
94+ overleaf={toOverleaf}
7995 onBodyChange={setBody}
8096 onIntentChange={setIntent}
8197 onCancel={() => props.onDismiss({ body, intent })}
8298 onSave={(revise) => {
8399 if (!mapped) return;
100+ const destinationNow = rounds.pdfNoteDestination(mapped.path);
101+ if (destinationNow === "other-file" || destinationNow === "offline") {
102+ setDestinationAtSave(destinationNow);
103+ return;
104+ }
84105 if (rounds.createFromPdf(selection, mapped, body, intent, revise)) props.onDone();
85106 else setError(t`The source changed since the PDF was built. Build again, then annotate.`);
86107 }}
src/locales/en/messages.po+28−0
@@ -834,6 +834,10 @@ msgstr "{path} is not a folder." · 833 unmodified lines
834834 msgid "{path} is not a UTF-8 path."
835835 msgstr "{path} is not a UTF-8 path."
836836
837+#: src/platform/host-errors-workspace.ts
838+msgid "{path} is not in the project folder."
839+msgstr "{path} is not in the project folder."
840+
837841 #: src/app/vanished-file.ts
838842 msgid "{path} was deleted"
839843 msgstr "{path} was deleted"
@@ -846,6 +850,10 @@ msgstr "{path} was moved to {moved}" · 6 unmodified lines
846850 msgid "{path}, line {line}"
847851 msgstr "{path}, line {line}"
848852
853+#: src/platform/host-errors-setup.ts
854+msgid "{path}, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document."
855+msgstr "{path}, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document."
856+
849857 #. placeholder {0}: peers.length
850858 #: src/papers/bibliography-audit.tsx
851859 msgid "{position} of {0}"
@@ -2587,6 +2595,10 @@ msgstr "Build log copied" · 1735 unmodified lines
25872595 msgid "Build notes"
25882596 msgstr "Build notes"
25892597
2598+#: src/app/use-panel-portals.tsx
2599+msgid "Build now compiles {path}."
2600+msgstr "Build now compiles {path}."
2601+
25902602 #: src/build/compile-diagnostics-panel.tsx
25912603 msgid "Build output"
25922604 msgstr "Build output"
@@ -3721,6 +3733,10 @@ msgstr "Comment from {opener}" · 1128 unmodified lines
37213733 msgid "Comment from {opener}, {0} messages"
37223734 msgstr "Comment from {opener}, {0} messages"
37233735
3736+#: src/canvas/annotation-composer.tsx
3737+msgid "Comment on Overleaf"
3738+msgstr "Comment on Overleaf"
3739+
37243740 #: src/editor/latex/latex-selection-toolbar.tsx
37253741 msgid "Comment on selected Markdown"
37263742 msgstr "Comment on selected Markdown"
@@ -10182,6 +10198,10 @@ msgstr "Overleaf sync is finishing. Try switching projects again in a moment." · 6455 unmodified lines
1018210198 msgid "Overleaf sync paused"
1018310199 msgstr "Overleaf sync paused"
1018410200
10201+#: src/canvas/pdf-annotation-composer.tsx
10202+msgid "Overleaf takes a comment only on the file open in the editor, and this passage is in {name}. Double-click it to open {name}, then select it again."
10203+msgstr "Overleaf takes a comment only on the file open in the editor, and this passage is in {name}. Double-click it to open {name}, then select it again."
10204+
1018510205 #: src/platform/host-errors-overleaf.ts
1018610206 msgid "Overleaf turned this request away, though the session is still valid."
1018710207 msgstr "Overleaf turned this request away, though the session is still valid."
@@ -12806,6 +12826,10 @@ msgctxt "macOS menu bar" · 2618 unmodified lines
1280612826 msgid "Services"
1280712827 msgstr "Services"
1280812828
12829+#: src/project/project-file-tree.tsx
12830+msgid "Set as main document"
12831+msgstr "Set as main document"
12832+
1280912833 #: src/settings/agent-environment-settings.tsx
1281012834 msgid "Set HTTPS_PROXY or ANTHROPIC_BASE_URL in your shell startup file, or turn on a proxy in macOS, then restart the agent."
1281112835 msgstr "Set HTTPS_PROXY or ANTHROPIC_BASE_URL in your shell startup file, or turn on a proxy in macOS, then restart the agent."
@@ -14812,6 +14836,10 @@ msgstr "This comment has no text yet" · 2000 unmodified lines
1481214836 msgid "This comment is no longer attached to any text, so Overleaf has nowhere to apply this."
1481314837 msgstr "This comment is no longer attached to any text, so Overleaf has nowhere to apply this."
1481414838
14839+#: src/canvas/pdf-annotation-composer.tsx
14840+msgid "This comment would go to Overleaf, but {name} is not being edited live with Overleaf right now. Reconnect live editing (or turn on Live sync), then select it again."
14841+msgstr "This comment would go to Overleaf, but {name} is not being edited live with Overleaf right now. Reconnect live editing (or turn on Live sync), then select it again."
14842+
1481514843 #: src/history/conflict-resolver.tsx
1481614844 msgid "This computer"
1481714845 msgstr "This computer"
src/locales/zh-CN/messages.po+28−0
@@ -834,6 +834,10 @@ msgstr "{path} 不是文件夹" · 833 unmodified lines
834834 msgid "{path} is not a UTF-8 path."
835835 msgstr "{path} 不是 UTF-8 路径"
836836
837+#: src/platform/host-errors-workspace.ts
838+msgid "{path} is not in the project folder."
839+msgstr "{path} 不在项目文件夹里"
840+
837841 #: src/app/vanished-file.ts
838842 msgid "{path} was deleted"
839843 msgstr "{path} 已被删除"
@@ -846,6 +850,10 @@ msgstr "{path} 已被移到 {moved}" · 6 unmodified lines
846850 msgid "{path}, line {line}"
847851 msgstr "{path} 第 {line} 行"
848852
853+#: src/platform/host-errors-setup.ts
854+msgid "{path}, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document."
855+msgstr "主文档 {path} 不见了。在“项目”面板里右键另一个 .tex 文件,选择“设为主文档”。"
856+
849857 #. placeholder {0}: peers.length
850858 #: src/papers/bibliography-audit.tsx
851859 msgid "{position} of {0}"
@@ -2587,6 +2595,10 @@ msgstr "已复制编译日志" · 1735 unmodified lines
25872595 msgid "Build notes"
25882596 msgstr "提示"
25892597
2598+#: src/app/use-panel-portals.tsx
2599+msgid "Build now compiles {path}."
2600+msgstr "现在编译 {path}"
2601+
25902602 #: src/build/compile-diagnostics-panel.tsx
25912603 msgid "Build output"
25922604 msgstr "编译结果"
@@ -3721,6 +3733,10 @@ msgstr "{opener} 的评论" · 1128 unmodified lines
37213733 msgid "Comment from {opener}, {0} messages"
37223734 msgstr "{opener} 的评论,共 {0} 条消息"
37233735
3736+#: src/canvas/annotation-composer.tsx
3737+msgid "Comment on Overleaf"
3738+msgstr "评论到 Overleaf"
3739+
37243740 #: src/editor/latex/latex-selection-toolbar.tsx
37253741 msgid "Comment on selected Markdown"
37263742 msgstr "评论所选"
@@ -10182,6 +10198,10 @@ msgstr "Overleaf 还在同步,请稍后再切换" · 6455 unmodified lines
1018210198 msgid "Overleaf sync paused"
1018310199 msgstr "同步已暂停"
1018410200
10201+#: src/canvas/pdf-annotation-composer.tsx
10202+msgid "Overleaf takes a comment only on the file open in the editor, and this passage is in {name}. Double-click it to open {name}, then select it again."
10203+msgstr "Overleaf 只接受编辑器中打开的文件上的评论,而这段文字在 {name} 里。双击它打开 {name},再重新选中。"
10204+
1018510205 #: src/platform/host-errors-overleaf.ts
1018610206 msgid "Overleaf turned this request away, though the session is still valid."
1018710207 msgstr "会话仍然有效,但 Overleaf 拒绝了这次请求"
@@ -12806,6 +12826,10 @@ msgctxt "macOS menu bar" · 2618 unmodified lines
1280612826 msgid "Services"
1280712827 msgstr "服务"
1280812828
12829+#: src/project/project-file-tree.tsx
12830+msgid "Set as main document"
12831+msgstr "设为主文档"
12832+
1280912833 #: src/settings/agent-environment-settings.tsx
1281012834 msgid "Set HTTPS_PROXY or ANTHROPIC_BASE_URL in your shell startup file, or turn on a proxy in macOS, then restart the agent."
1281112835 msgstr "在 shell 启动文件中设置 HTTPS_PROXY 或 ANTHROPIC_BASE_URL,或在 macOS 中开启代理,然后重启 AI 助手。"
@@ -14812,6 +14836,10 @@ msgstr "还没写内容" · 2000 unmodified lines
1481214836 msgid "This comment is no longer attached to any text, so Overleaf has nowhere to apply this."
1481314837 msgstr "这条评论已没有对应文字,无法操作"
1481414838
14839+#: src/canvas/pdf-annotation-composer.tsx
14840+msgid "This comment would go to Overleaf, but {name} is not being edited live with Overleaf right now. Reconnect live editing (or turn on Live sync), then select it again."
14841+msgstr "这条评论会发到 Overleaf,但 {name} 现在没有与 Overleaf 实时编辑。重新连接实时编辑(或打开实时同步)后再重新选中。"
14842+
1481514843 #: src/history/conflict-resolver.tsx
1481614844 msgid "This computer"
1481714845 msgstr "本机"
src/overleaf/overleaf-editor-comments.ts+47−0
@@ -0,0 +1,47 @@
1+/**
2+ * Overleaf's comment threads as the editor and the comments panel show them:
3+ * one comment per anchored thread, on the file and span it is anchored to.
4+ */
5+import type { EditorComment } from "../editor/comments/editor-comment-data";
6+import { personColor, type PeopleHues } from "./overleaf-collab-people";
7+import type { OverleafComments } from "./use-overleaf-comments";
8+
9+/** Marks a comment that lives on Overleaf rather than in this project; App's comment handlers route on it. */
10+export const OVERLEAF_COMMENT_PREFIX = "overleaf:";
11+
12+export function projectOverleafEditorComments(
13+ threads: OverleafComments["threads"],
14+ anchors: OverleafComments["anchors"],
15+ docPaths: Map<string, string>,
16+ liveDocId: string | null,
17+ liveAnchors: Map<string, { threadId: string; position: number; quote: string }>,
18+ hues: PeopleHues = new Map(),
19+): EditorComment[] {
20+ // REST supplies ownership for every file; the joined document's live ranges
21+ // are authoritative, including anchors removed since the REST snapshot.
22+ const mergedAnchors = new Map([...anchors].filter(([, anchor]) => anchor.docId !== liveDocId));
23+ if (liveDocId) {
24+ for (const [id, anchor] of liveAnchors) mergedAnchors.set(id, { ...anchor, docId: liveDocId });
25+ }
26+ return threads.flatMap((thread) => {
27+ const anchor = mergedAnchors.get(thread.id);
28+ const path = anchor && docPaths.get(anchor.docId);
29+ if (!anchor || !path) return [];
30+ const [first, ...rest] = thread.messages;
31+ return [{
32+ id: `${OVERLEAF_COMMENT_PREFIX}${thread.id}`, number: 0, path,
33+ from: anchor.position, to: anchor.position + anchor.quote.length, quote: anchor.quote, prefix: "", suffix: "",
34+ body: first?.content ?? "", authorId: first?.authorEmail ?? "overleaf",
35+ authorColor: first && personColor(hues, { name: first.authorName, email: first.authorEmail, mine: first.mine }),
36+ // eslint-disable-next-line lingui/no-unlocalized-strings -- a name tagged with the product, the same in every locale
37+ authorName: first ? `${first.authorName} (Overleaf)` : "Overleaf", resolved: thread.resolved,
38+ replies: rest.map((message) => ({
39+ id: message.id, authorId: message.authorEmail ?? "overleaf", authorName: message.authorName,
40+ authorColor: personColor(hues, { name: message.authorName, email: message.authorEmail, mine: message.mine }),
41+ body: message.content, createdAt: new Date(message.timestamp).toISOString(),
42+ })),
43+ createdAt: new Date(first?.timestamp ?? 0).toISOString(),
44+ updatedAt: new Date(thread.messages.at(-1)?.timestamp ?? 0).toISOString(),
45+ }];
46+ });
47+}
src/platform/host-error-text.test.ts+4−0
@@ -37,6 +37,10 @@ describe("hostErrorText", () => { · 36 unmodified lines
3737 expect(hostErrorText(english)).toMatch(/\p{Script=Han}/u);
3838 }
3939 expect(hostErrorText("Root document not found: main.tex")).toBe("找不到主文档:main.tex");
40+ // A main document a sync or Finder removed: which file, and where to choose another.
41+ expect(hostErrorText("main.tex is not in the project folder.")).toBe("main.tex 不在项目文件夹里");
42+ expect(hostErrorText("main.tex, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document."))
43+ .toBe("主文档 main.tex 不见了。在“项目”面板里右键另一个 .tex 文件,选择“设为主文档”。");
4044 expect(hostErrorText("Keep the owner to at most 80 characters.")).toBe("负责人最多 80 个字符");
4145 });
4246
src/platform/host-errors-setup.ts+1−1
@@ -55,7 +55,7 @@ export const SETUP_HOST_ERRORS: readonly HostErrorEntry[] = [ · 54 unmodified lines
5555
5656 // The document a build compiles (latex/build.rs names its argument;
5757 // latex/pinned.rs and texcount.rs write the positional form)
58− ["latex/build.rs", "Root document not found: {document}", ([path]) => t`Root document not found: ${path}`],
58+ ["latex/build.rs", "{document}, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document.", ([path]) => t`${path}, the main document, is missing. Right-click another .tex file in the Project panel and choose Set as main document.`],
5959 ["latex/pinned.rs", "Root document not found: {}", ([path]) => t`Root document not found: ${path}`],
6060
6161 // The built-in agent's startup (synara.rs). The startup log is the
src/platform/host-errors-workspace.ts+2−0
@@ -38,6 +38,8 @@ export const WORKSPACE_HOST_ERRORS: readonly HostErrorEntry[] = [ · 37 unmodified lines
3838 ["project/index_sources.rs", "Could not read {}: {message}", ([path, error]) => t`Could not read ${path}: ${error}`],
3939 ["overleaf/files.rs", "Could not write {rel}: {error}", ([path, error]) => t`Could not write ${path}: ${error}`],
4040 ["overleaf/sync.rs", "Could not delete {path}: {e}", ([path, error]) => t`Could not delete ${path}: ${error}`],
41+ // A file a tab or a restored workspace names, removed by a sync or Finder (project/tree.rs).
42+ ["project/tree.rs", "{relative} is not in the project folder.", ([path]) => t`${path} is not in the project folder.`],
4143
4244 // The window's project (ipc.rs)
4345 ["ipc.rs", "Open a project first.", () => t`Open a project first.`],
src/project/project-file-tree.test.tsx+32−0
@@ -38,6 +38,8 @@ function baseProps(): ProjectFileTreeProps { · 37 unmodified lines
3838 activeFile: "main.tex",
3939 activeAssetPath: "",
4040 protectedPaths: [],
41+ mainDocument: "main.tex",
42+ onSetMainDocument: vi.fn(),
4143 onFile: vi.fn(),
4244 onAsset: vi.fn(),
4345 onBeginFigureDrag: vi.fn(),
@@ -285,6 +287,36 @@ describe("ProjectFileTree", () => { · 241 unmodified lines
285287 await waitFor(() => expect(view.props.onDeleteEntries).toHaveBeenCalledWith(["main.tex", "sections/intro.tex"]));
286288 });
287289
290+ // Overleaf's Main document setting: which file Build compiles is the writer's to choose.
291+ it("sets a .tex file as the main document from its menu, and checks the one that is", async () => {
292+ expandSections();
293+ const view = renderTree();
294+ fireEvent.contextMenu(await findTreeItem("main.tex"));
295+ const current = await screen.findByRole("menuitemcheckbox", { name: "Set as main document" });
296+ expect(current).toHaveAttribute("aria-checked", "true");
297+ expect(current.querySelector("svg")).toHaveClass("lucide-check");
298+ fireEvent.click(current);
299+ expect(view.props.onSetMainDocument).not.toHaveBeenCalled();
300+
301+ fireEvent.contextMenu(await findTreeItem("sections/intro.tex"));
302+ const other = await screen.findByRole("menuitemcheckbox", { name: "Set as main document" });
303+ expect(other).toHaveAttribute("aria-checked", "false");
304+ fireEvent.click(other);
305+ await waitFor(() => expect(view.props.onSetMainDocument).toHaveBeenCalledWith("sections/intro.tex"));
306+ expect(view.props.onSetMainDocument).toHaveBeenCalledTimes(1);
307+ // A default no one chose (the open file built it) can still be pinned.
308+ view.rerenderWith({ mainDocument: null });
309+ fireEvent.contextMenu(await findTreeItem("main.tex"));
310+ const unchosen = await screen.findByRole("menuitemcheckbox", { name: "Set as main document" });
311+ expect(unchosen).toHaveAttribute("aria-checked", "false");
312+ fireEvent.click(unchosen);
313+ await waitFor(() => expect(view.props.onSetMainDocument).toHaveBeenCalledWith("main.tex"));
314+ // A folder is never compiled.
315+ fireEvent.contextMenu(await findTreeItem("sections/"));
316+ await screen.findByRole("menuitem", { name: "Rename" });
317+ expect(screen.queryByRole("menuitemcheckbox", { name: "Set as main document" })).toBeNull();
318+ });
319+
288320 it("keeps a pressed row from scrolling itself into view, so a row cut off at the edge still opens", async () => {
289321 expandSections();
290322 const view = renderTree();
src/project/project-file-tree.tsx+16−1
@@ -4,7 +4,9 @@ import { useLingui } from "@lingui/react/macro"; · 3 unmodified lines
44 import { writeText } from "@tauri-apps/plugin-clipboard-manager";
55 import type { ContextMenuItem as PierreContextMenuItem, ContextMenuOpenContext, FileTreeDropTarget } from "@pierre/trees";
66 import { FileTree, useFileTree } from "@pierre/trees/react";
7−import { Check, ClipboardPaste, Copy, Eye, FilePlus, FolderInput, FolderOpen, FolderPlus, ImagePlus, Pencil, RotateCw } from "lucide-react";
7+import {
8+ Check, ClipboardPaste, Copy, Eye, FilePlay, FilePlus, FolderInput, FolderOpen, FolderPlus, ImagePlus, Pencil, RotateCw,
9+} from "lucide-react";
810 import { ContextMenu, ContextMenuTrigger } from "../components/ui/context-menu";
911 import { ExternalScrollbar } from "../components/ui/external-scrollbar";
1012 import { absoluteProjectPath } from "../app-utils";
@@ -52,6 +54,12 @@ export type ProjectFileTreeProps = { · 41 unmodified lines
5254 activeFile: string;
5355 activeAssetPath: string;
5456 protectedPaths: string[];
57+ /**
58+ * The .tex file the writer chose as the main document, which a .tex file's
59+ * menu can change; `null` while builds follow the open file instead.
60+ */
61+ mainDocument: string | null;
62+ onSetMainDocument: (path: string) => void;
5563 onFile: (path: string) => void;
5664 onLikelyFile?: (path: string) => void;
5765 onAsset: (path: string) => void;
@@ -360,6 +368,13 @@ export function ProjectFileTree(props: ProjectFileTreeProps) { · 302 unmodified lines
360368 { icon: ImagePlus, label: t`Import images here`, disabled: props.assetImporting, run: () => props.onImportAssets(path) },
361369 { icon: ClipboardPaste, label: t`Paste clipboard image as figure`, run: () => props.onPasteImage(path) },
362370 ] : []),
371+ // Which file Build compiles is the writer's to choose, as in Overleaf's
372+ // Main document setting; the check says which one was chosen.
373+ ...(!directory && /\.tex$/i.test(path) ? [{
374+ icon: path === props.mainDocument ? Check : FilePlay, label: t`Set as main document`, group: true,
375+ checked: path === props.mainDocument,
376+ run: () => { if (path !== props.mainDocument) props.onSetMainDocument(path); },
377+ }] : []),
363378 { icon: Pencil, label: t`Rename`, group: true, run: () => model.startRenaming(item.path) },
364379 {
365380 icon: Copy,