* 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
Unified · Split
| @@ -290,7 +290,7 @@ Practical consequence: to find what a button does, grep the command name in · 289 unmodified lines | ||
| 290 | 290 | | `search` | 10 | `search_project`, `replace_in_project`, `rename_symbol`, `list_todos` | |
| 291 | 291 | | `windows` | 11 | `open_project_window`, `open_in_browser`, `return_to_desktop`, `set_window_material` | |
| 292 | 292 | | `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` | | |
| 294 | 294 | | `papers` | 12 | `search_literature`, `fetch_paper`, `import_reference`, `read_paper` | |
| 295 | 295 | | `git` | 10 | `git_status`, `git_user_name`, `git_log`, `git_show_diff`, `git_compare_files`, `git_restore_project`, `git_auto_commit` | |
| 296 | 296 | | `history` | 5 | `list_history`, `get_history_entry`, `revert_transaction` | |
| @@ -5,12 +5,12 @@ · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | use super::overleaf::{overleaf_config_dir, settled}; |
| 7 | 7 | 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}; | |
| 9 | 9 | use crate::overleaf; |
| 10 | −use crate::overleaf_rt::{self, RealtimeClient}; | |
| 10 | +use crate::overleaf_rt::{self, RealtimeClient, RealtimeEvent}; | |
| 11 | 11 | use std::collections::{BTreeMap, BTreeSet}; |
| 12 | 12 | use std::path::{Path, PathBuf}; |
| 13 | −use std::sync::Arc; | |
| 13 | +use std::sync::{Arc, Weak}; | |
| 14 | 14 | use tauri::{AppHandle, Emitter, State, Window}; |
| 15 | 15 | |
| 16 | 16 | #[derive(Default)] |
| @@ -25,6 +25,9 @@ pub(crate) struct OverleafRealtimeState { · 8 unmodified lines | ||
| 25 | 25 | /// the same lease that prevents a new join until the sync finishes. |
| 26 | 26 | joined_paths: BTreeMap<String, String>, |
| 27 | 27 | 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>, | |
| 28 | 31 | } |
| 29 | 32 | |
| 30 | 33 | impl OverleafRealtimeState { |
| @@ -35,6 +38,7 @@ impl OverleafRealtimeState { · 4 unmodified lines | ||
| 35 | 38 | self.root = root; |
| 36 | 39 | self.joined_paths.clear(); |
| 37 | 40 | self.join_receipts.clear(); |
| 41 | + self.root_document = None; | |
| 38 | 42 | self.client.take() |
| 39 | 43 | } |
| 40 | 44 | |
| @@ -175,13 +179,27 @@ pub async fn overleaf_rt_connect( · 134 unmodified lines | ||
| 175 | 179 | } |
| 176 | 180 | |
| 177 | 181 | let event_state = Arc::clone(&project.realtime); |
| 182 | + let event_project = Arc::downgrade(&project); | |
| 178 | 183 | let event_root = root.clone(); |
| 179 | 184 | let emit_label = window.label().to_string(); |
| 180 | 185 | let emitter = app.clone(); |
| 181 | 186 | let connecting = RealtimeClient::connect( |
| 182 | 187 | overleaf_rt::RealtimeConfig { user_id, host, cookie, project_id }, |
| 183 | 188 | 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 { | |
| 185 | 203 | let event = ScopedEvent { project_root: &event_root, event }; |
| 186 | 204 | let _ = emitter.emit_to(emit_label.as_str(), "overleaf-realtime", event); |
| 187 | 205 | } |
| @@ -213,6 +231,7 @@ pub async fn overleaf_rt_connect( · 25 unmodified lines | ||
| 213 | 231 | let mut realtime = project.realtime()?; |
| 214 | 232 | if realtime.owns(generation, &root) { |
| 215 | 233 | realtime.client = Some(Arc::clone(&client)); |
| 234 | + realtime.root_document = tree.root_document.clone(); | |
| 216 | 235 | installed = true; |
| 217 | 236 | } |
| 218 | 237 | } |
| @@ -227,17 +246,45 @@ pub async fn overleaf_rt_connect( · 8 unmodified lines | ||
| 227 | 246 | // that was already under way. |
| 228 | 247 | let root_folder_id = tree.root_folder_id.clone(); |
| 229 | 248 | 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; | |
| 231 | 250 | if current_root(&state, &window)? != root { |
| 232 | 251 | return Err("The project changed before Overleaf could finish connecting.".to_string()); |
| 233 | 252 | } |
| 253 | + let root_document = reported_root_document(&lease.project, generation, &root)?; | |
| 234 | 254 | 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 | + } | |
| 236 | 260 | }) |
| 237 | 261 | .await?; |
| 238 | 262 | Ok(joined) |
| 239 | 263 | } |
| 240 | 264 | |
| 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 | + | |
| 241 | 288 | #[tauri::command] |
| 242 | 289 | pub fn overleaf_rt_disconnect( |
| 243 | 290 | state: State<'_, AppState>, window: Window, project_root: Option<String>, |
| @@ -234,6 +234,25 @@ pub async fn update_project_manifest( · 233 unmodified lines | ||
| 234 | 234 | .await |
| 235 | 235 | } |
| 236 | 236 | |
| 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 | + | |
| 237 | 256 | #[tauri::command] |
| 238 | 257 | pub async fn set_project_deadline( |
| 239 | 258 | state: State<'_, AppState>, window: Window, deadline: Option<ProjectDeadline>, |
| @@ -156,10 +156,10 @@ pub fn classify_build_error(error: &str) -> Failure { · 155 unmodified lines | ||
| 156 | 156 | } |
| 157 | 157 | } else if error == ALREADY_RUNNING { |
| 158 | 158 | 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.") { | |
| 160 | 160 | Failure { |
| 161 | 161 | 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.", | |
| 163 | 163 | } |
| 164 | 164 | } else if error.starts_with("The project changed") { |
| 165 | 165 | 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 | ||
| 170 | 170 | |
| 171 | 171 | const ALREADY_RUNNING: &str = "A build is already running."; |
| 172 | 172 | |
| 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 | + | |
| 173 | 179 | pub fn build( |
| 174 | 180 | root: &Path, force: bool, active: &ActiveBuild, open_document: Option<&str>, steps: &BuildSteps, |
| 175 | 181 | ) -> Result<BuildResult, String> { |
| @@ -316,7 +322,7 @@ fn run_latexmk_with( · 140 unmodified lines | ||
| 316 | 322 | let document = compiled_document(root, &manifest)?; |
| 317 | 323 | let root_document = project::safe_path(root, &document)?; |
| 318 | 324 | if !root_document.exists() { |
| 319 | − return Err(format!("Root document not found: {document}")); | |
| 325 | + return Err(missing_root(&document)); | |
| 320 | 326 | } |
| 321 | 327 | let prepared = if use_format { |
| 322 | 328 | preamble_format::prepare(root, &manifest, &document, &root_document, active, steps) |
| @@ -497,7 +503,7 @@ mod tests { · 174 unmodified lines | ||
| 497 | 503 | "toolchain_missing" |
| 498 | 504 | ); |
| 499 | 505 | 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"); | |
| 501 | 507 | assert_eq!(kind("The project changed before its build could start."), "project_changed"); |
| 502 | 508 | assert_eq!(kind("latexmk exited unexpectedly: signal"), "build_error"); |
| 503 | 509 | } |
| @@ -42,9 +42,10 @@ pub(super) fn compiled_document(root: &Path, manifest: &ProjectManifest) -> Resu · 41 unmodified lines | ||
| 42 | 42 | default_root_document(manifest).map(|document| document.path.clone()) |
| 43 | 43 | } |
| 44 | 44 | |
| 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 | |
| 48 | 49 | /// a chapter opened next still compiles this document, and everything that |
| 49 | 50 | /// resolves the default root (PDF preview, SyncTeX, clean) agrees on what was |
| 50 | 51 | /// 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 | ||
| 53 | 54 | pub(super) fn choose_compile_root(root: &Path, open_document: Option<&str>) -> Result<(), String> { |
| 54 | 55 | match open_document.and_then(|open| project::resolve_compile_root(root, open)) { |
| 55 | 56 | 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) => { | |
| 57 | 58 | set_one_off_root(root, None); |
| 58 | 59 | project::set_compile_root(root, &target.path)?; |
| 59 | 60 | } |
| 60 | − None => set_one_off_root(root, None), | |
| 61 | + _ => set_one_off_root(root, None), | |
| 61 | 62 | } |
| 62 | 63 | Ok(()) |
| 63 | 64 | } |
| @@ -107,4 +108,39 @@ mod tests { · 43 unmodified lines | ||
| 107 | 108 | } |
| 108 | 109 | assert_eq!(project::read_manifest(&root).unwrap().root_documents.len(), 1); |
| 109 | 110 | } |
| 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 | + } | |
| 110 | 146 | } |
| @@ -439,6 +439,7 @@ fn subfolder_project(engine: &str) -> (TempDir, Vec<u8>) { · 438 unmodified lines | ||
| 439 | 439 | path: "cv/sop/sop.tex".to_string(), |
| 440 | 440 | name: "sop".to_string(), |
| 441 | 441 | is_default: true, |
| 442 | + chosen: false, | |
| 442 | 443 | }); |
| 443 | 444 | project::write_manifest(&root, &manifest).unwrap(); |
| 444 | 445 | root.write( |
| @@ -421,6 +421,7 @@ pub fn run() { · 420 unmodified lines | ||
| 421 | 421 | ipc::workspace::export_arxiv_package, |
| 422 | 422 | ipc::workspace::refresh_project, |
| 423 | 423 | ipc::workspace::update_project_manifest, |
| 424 | + ipc::workspace::set_root_document, | |
| 424 | 425 | ipc::workspace::set_project_deadline, |
| 425 | 426 | ipc::workspace::set_project_spelling_words, |
| 426 | 427 | ipc::workspace::set_project_grammar_ignores, |
| @@ -12,6 +12,10 @@ pub struct RootDocument { · 11 unmodified lines | ||
| 12 | 12 | pub path: String, |
| 13 | 13 | pub name: String, |
| 14 | 14 | 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, | |
| 15 | 19 | } |
| 16 | 20 | |
| 17 | 21 | fn default_pdf_engine() -> String { |
| @@ -46,6 +46,7 @@ mod files; · 45 unmodified lines | ||
| 46 | 46 | mod link; |
| 47 | 47 | mod projects; |
| 48 | 48 | mod review; |
| 49 | +mod root_document; | |
| 49 | 50 | mod sync; |
| 50 | 51 | #[cfg(test)] |
| 51 | 52 | mod test_support; |
| @@ -70,6 +71,7 @@ pub use review::{ · 18 unmodified lines | ||
| 70 | 71 | resolve_thread, send_chat_message, threads, OverleafCommentAnchor, OverleafMessage, |
| 71 | 72 | OverleafThread, |
| 72 | 73 | }; |
| 74 | +pub use root_document::{record_root_document, reported_root_document}; | |
| 73 | 75 | pub use sync::{ |
| 74 | 76 | classify_sync_error, preview, probe, sync, sync_relocations, OverleafPreview, OverleafProbe, |
| 75 | 77 | OverleafSyncResult, |
| @@ -77,6 +77,10 @@ pub(super) struct SyncState { · 76 unmodified lines | ||
| 77 | 77 | /// [`SyncState::access`]). |
| 78 | 78 | #[serde(default)] |
| 79 | 79 | 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>, | |
| 80 | 84 | /// Relative path (forward slashes) → sha256 hex of the content at the |
| 81 | 85 | /// last successful sync. |
| 82 | 86 | #[serde(default)] |
| @@ -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 | +} | |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | use super::codec::*; |
| 5 | 5 | use super::events::*; |
| 6 | −use super::tree::{parse_project, NodeKind, Tree}; | |
| 6 | +use super::tree::{parse_project, NodeKind, ProjectTree, Tree}; | |
| 7 | 7 | use super::{NOT_CONNECTED, SESSION_EXPIRED, USER_AGENT}; |
| 8 | 8 | use crate::util::url_encode; |
| 9 | 9 | use futures_util::sink::{Sink, SinkExt}; |
| @@ -201,9 +201,9 @@ impl Shared { · 191 unmodified lines | ||
| 201 | 201 | fn edit_tree(&self, edit: impl FnOnce(&mut Tree) -> bool) { |
| 202 | 202 | let mut tree = lock(&self.tree); |
| 203 | 203 | if edit(&mut tree) { |
| 204 | − let (docs, entities) = (tree.docs(), tree.entities()); | |
| 204 | + let changed = tree.changed(); | |
| 205 | 205 | drop(tree); |
| 206 | − self.emit(RealtimeEvent::TreeChanged { docs, entities }); | |
| 206 | + self.emit(changed); | |
| 207 | 207 | } |
| 208 | 208 | } |
| 209 | 209 | } |
| @@ -410,6 +410,8 @@ fn handle_event(shared: &Arc<Shared>, data: &str) -> Result<(), String> { · 200 unmodified lines | ||
| 410 | 410 | shared.edit_tree(|tree| tree.move_to(id, folder)); |
| 411 | 411 | } |
| 412 | 412 | } |
| 413 | + // Someone chose another main document in Overleaf's settings. | |
| 414 | + "rootDocUpdated" => shared.edit_tree(|tree| tree.set_root_doc(arg(0))), | |
| 413 | 415 | "removeEntity" => { |
| 414 | 416 | if let Some(id) = arg(0) { |
| 415 | 417 | shared.edit_tree(|tree| tree.remove(id)); |
| @@ -499,27 +501,6 @@ pub struct RealtimeClient { · 83 unmodified lines | ||
| 499 | 501 | project: ProjectTree, |
| 500 | 502 | } |
| 501 | 503 | |
| 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 | − | |
| 523 | 504 | // Hand-written because `Shared` holds the app's event callback; `Debug` is what |
| 524 | 505 | // lets callers `expect_err` on `connect`. |
| 525 | 506 | impl std::fmt::Debug for RealtimeClient { |
| @@ -630,6 +611,7 @@ impl RealtimeClient { · 104 unmodified lines | ||
| 630 | 611 | root_folder_id: tree.root.clone(), |
| 631 | 612 | docs: tree.docs(), |
| 632 | 613 | entities: tree.entities(), |
| 614 | + root_document: tree.root_document(), | |
| 633 | 615 | user_id: config.user_id.clone(), |
| 634 | 616 | permission, |
| 635 | 617 | track_changes: track_changes_for(track_changes.as_ref(), config.user_id.as_deref()), |
| @@ -62,6 +62,8 @@ pub enum RealtimeEvent { · 61 unmodified lines | ||
| 62 | 62 | TreeChanged { |
| 63 | 63 | docs: Vec<DocEntry>, |
| 64 | 64 | entities: Vec<EntityEntry>, |
| 65 | + /// The main document Overleaf compiles, as a path (see `ProjectTree`). | |
| 66 | + root_document: Option<String>, | |
| 65 | 67 | }, |
| 66 | 68 | /// Someone in the project moved, or appeared for the first time. |
| 67 | 69 | /// |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | //! path. So the only way to keep a path→document map current without |
| 7 | 7 | //! re-reading the project is to hold the tree itself and resolve paths from it. |
| 8 | 8 | |
| 9 | −use super::events::{DocEntry, EntityEntry, Permission}; | |
| 9 | +use super::events::{DocEntry, EntityEntry, Permission, RealtimeEvent}; | |
| 10 | +use serde::Serialize; | |
| 10 | 11 | use serde_json::Value; |
| 11 | 12 | use std::collections::HashMap; |
| 12 | 13 | |
| @@ -36,6 +37,33 @@ struct TreeNode { · 23 unmodified lines | ||
| 36 | 37 | pub(super) struct Tree { |
| 37 | 38 | nodes: HashMap<String, TreeNode>, |
| 38 | 39 | 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, | |
| 39 | 67 | } |
| 40 | 68 | |
| 41 | 69 | impl Tree { |
| @@ -43,6 +71,7 @@ impl Tree { · 1 unmodified line | ||
| 43 | 71 | let mut tree = Tree { |
| 44 | 72 | nodes: HashMap::new(), |
| 45 | 73 | root: root.get("_id").and_then(Value::as_str).unwrap_or_default().to_string(), |
| 74 | + root_doc: None, | |
| 46 | 75 | }; |
| 47 | 76 | tree.absorb(root, None, 0); |
| 48 | 77 | tree |
| @@ -126,6 +155,25 @@ impl Tree { · 77 unmodified lines | ||
| 126 | 155 | entities.map(|entity| DocEntry { id: entity.id, path: entity.path }).collect() |
| 127 | 156 | } |
| 128 | 157 | |
| 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 | + | |
| 129 | 177 | pub fn rename(&mut self, id: &str, name: &str) -> bool { |
| 130 | 178 | self.nodes.get_mut(id).map(|node| node.name = name.to_string()).is_some() |
| 131 | 179 | } |
| @@ -189,5 +237,49 @@ pub(super) fn parse_project(body: &[Value]) -> Result<(Tree, Permission, Option< · 57 unmodified lines | ||
| 189 | 237 | return Err("Overleaf's root folder has no id.".to_string()); |
| 190 | 238 | } |
| 191 | 239 | 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 | + } | |
| 193 | 285 | } |
| @@ -88,8 +88,9 @@ pub use linked_bibliography::{ · 87 unmodified lines | ||
| 88 | 88 | }; |
| 89 | 89 | pub(crate) use manifest::ensure_ignore_line; |
| 90 | 90 | 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, | |
| 93 | 94 | }; |
| 94 | 95 | pub use manifest_settings::{ |
| 95 | 96 | set_deadline, set_grammar_ignores, set_spelling_words, set_submission_items, |
| @@ -194,6 +194,7 @@ fn default_manifest_with_venue(name: &str, venue: Venue) -> ProjectManifest { · 193 unmodified lines | ||
| 194 | 194 | path: "main.tex".to_string(), |
| 195 | 195 | name: "Main paper".to_string(), |
| 196 | 196 | is_default: true, |
| 197 | + chosen: false, | |
| 197 | 198 | }], |
| 198 | 199 | primary_bibliography: "references.bib".to_string(), |
| 199 | 200 | trusted: false, |
| @@ -52,7 +52,8 @@ pub fn open(root: &Path) -> Result<ProjectSnapshot, String> { · 51 unmodified lines | ||
| 52 | 52 | let adopted = !root.join(MANIFEST_PATH).exists(); |
| 53 | 53 | wide_event::record("adopted", adopted); |
| 54 | 54 | 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 { | |
| 56 | 57 | write_manifest(&root, &manifest)?; |
| 57 | 58 | } |
| 58 | 59 | 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 | ||
| 203 | 204 | path: relative.to_string(), |
| 204 | 205 | name: document_name(relative, fallback_name), |
| 205 | 206 | is_default: false, |
| 207 | + chosen: false, | |
| 206 | 208 | }); |
| 207 | 209 | changed = true; |
| 208 | 210 | } |
| @@ -210,6 +212,35 @@ fn make_default_root(manifest: &mut ProjectManifest, relative: &str, fallback_na · 1 unmodified line | ||
| 210 | 212 | let is_default = document.path == relative; |
| 211 | 213 | changed |= document.is_default != is_default; |
| 212 | 214 | 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 | + } | |
| 213 | 244 | } |
| 214 | 245 | changed |
| 215 | 246 | } |
| @@ -416,6 +447,30 @@ pub fn set_compile_root(root: &Path, path: &str) -> Result<ProjectManifest, Stri · 200 unmodified lines | ||
| 416 | 447 | Ok(manifest) |
| 417 | 448 | } |
| 418 | 449 | |
| 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 | + | |
| 419 | 474 | /// Pick the best root `.tex` for foreign / Overleaf-style trees. |
| 420 | 475 | fn detect_root_document(root: &Path) -> Option<String> { |
| 421 | 476 | let tex_files = || { |
| @@ -268,6 +268,7 @@ pub fn instantiate( · 267 unmodified lines | ||
| 268 | 268 | path: file.clone(), |
| 269 | 269 | name: file.clone(), |
| 270 | 270 | is_default: *file == item.root, |
| 271 | + chosen: false, | |
| 271 | 272 | }) |
| 272 | 273 | .collect(); |
| 273 | 274 | manifest::write_manifest(&root, &manifest)?; |
| @@ -407,7 +407,12 @@ const NOT_EDITABLE: &str = · 406 unmodified lines | ||
| 407 | 407 | pub fn read_file(root: &Path, relative: &str) -> Result<String, String> { |
| 408 | 408 | let path = safe_path(root, relative)?; |
| 409 | 409 | // 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 | + })?; | |
| 411 | 416 | if !metadata.file_type().is_file() { |
| 412 | 417 | return Err(NOT_EDITABLE.to_string()); |
| 413 | 418 | } |
| @@ -576,7 +576,7 @@ function App({ surfaces }: { surfaces: AppSurfaces }) { · 575 unmodified lines | ||
| 576 | 576 | useLayoutEffect(() => { |
| 577 | 577 | annotationRounds.attach({ |
| 578 | 578 | store: editorComments, projectRootRef, activeFileRef, sourceRef, openProjectFile: openFile, |
| 579 | − author: { id: editorCommentAuthorId, name: authorName }, overleafLinked: overleafLink !== null, | |
| 579 | + author: { id: editorCommentAuthorId, name: authorName }, | |
| 580 | 580 | openPanel: () => tools.open("comments"), |
| 581 | 581 | openHistoryEntry: (historyEntry) => tools.open("history", { historyEntry }), |
| 582 | 582 | save, |
| @@ -10,6 +10,8 @@ type RootDocument = { · 9 unmodified lines | ||
| 10 | 10 | path: string; |
| 11 | 11 | name: string; |
| 12 | 12 | isDefault: boolean; |
| 13 | + /** The writer chose this default (Set as main document): builds compile it whichever file is open. */ | |
| 14 | + chosen?: boolean; | |
| 13 | 15 | }; |
| 14 | 16 | |
| 15 | 17 | export type ProjectVenue = |
| @@ -9,7 +9,9 @@ import { baseName } from "../lib/base-name"; · 8 unmodified lines | ||
| 9 | 9 | export function adoptRootDocument(project: ProjectSnapshot, rootDocument: string): ProjectSnapshot { |
| 10 | 10 | const documents = project.manifest.rootDocuments; |
| 11 | 11 | 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 | + })); | |
| 13 | 15 | if (!documents.some((document) => document.path === rootDocument)) { |
| 14 | 16 | const stem = baseName(rootDocument).replace(/\.tex$/i, ""); |
| 15 | 17 | rootDocuments.push({ path: rootDocument, name: stem || rootDocument, isDefault: true }); |
| @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { reviseAnnotationWithAgent } from "../agent/agent-annotations"; |
| 4 | 4 | import { annotateSelection } from "../canvas/annotate-selection"; |
| 5 | 5 | import { createEditorComment, type AnnotationIntent, type AnnotationRound, type EditorComment } from "../editor/comments/editor-comment-data"; |
| 6 | +import { sourceRangeForPdfText } from "../editor/comments/pdf-source-anchor"; | |
| 6 | 7 | import { AnnotationRounds, type AnnotationRoundsDeps } from "./annotation-rounds"; |
| 7 | 8 | |
| 8 | 9 | vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); |
| @@ -39,12 +40,13 @@ function roundsWith(annotations: EditorComment[]) { · 30 unmodified lines | ||
| 39 | 40 | state.current = { ...state.current, annotations: [...state.current.annotations, created] }; |
| 40 | 41 | return created; |
| 41 | 42 | }, |
| 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(), | |
| 43 | 44 | }; |
| 44 | 45 | const rounds = new AnnotationRounds(); |
| 46 | + const sourceRef = { current: SOURCE }; | |
| 45 | 47 | 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(), | |
| 48 | 50 | openHistoryEntry: vi.fn(), save: vi.fn(async () => true), reloadAfterRestore: vi.fn(async () => {}), |
| 49 | 51 | }); |
| 50 | 52 | /** The passages the agent was asked to revise, once every task has answered. */ |
| @@ -53,7 +55,7 @@ function roundsWith(annotations: EditorComment[]) { · 2 unmodified lines | ||
| 53 | 55 | return vi.mocked(reviseAnnotationWithAgent).mock.calls.map(([request]) => request.passage); |
| 54 | 56 | }; |
| 55 | 57 | 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 }; | |
| 57 | 59 | } |
| 58 | 60 | |
| 59 | 61 | beforeEach(() => { |
| @@ -63,6 +65,38 @@ beforeEach(() => { · 3 unmodified lines | ||
| 63 | 65 | })); |
| 64 | 66 | }); |
| 65 | 67 | |
| 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 | + | |
| 66 | 100 | describe("AnnotationRounds / revise scope", () => { |
| 67 | 101 | // The captain's case: two notes are open, the writer selects a third passage, |
| 68 | 102 | // picks Shorten and saves with Save and revise now. Only that passage changes. |
| @@ -24,7 +24,7 @@ import type { PdfTextSelection } from "../pdf/pdf-annotations"; · 23 unmodified lines | ||
| 24 | 24 | import { notifyInfo } from "../telemetry/app-notify"; |
| 25 | 25 | import { showError } from "./notify"; |
| 26 | 26 | import { createAnnotationReviewBridge } from "../canvas/annotation-review-bridge"; |
| 27 | −import type { EditorComments } from "./use-editor-comments"; | |
| 27 | +import type { CommentDestination, EditorComments } from "./use-editor-comments"; | |
| 28 | 28 | |
| 29 | 29 | /** Text tasks the agent service runs at once is four; one stays free for a proofread. */ |
| 30 | 30 | const CONCURRENT_REVISIONS = 3; |
| @@ -76,15 +76,13 @@ const lineAt = (source: string, offset: number) => source.slice(0, offset).split · 45 unmodified lines | ||
| 76 | 76 | |
| 77 | 77 | /** What the rounds read and do in the app, attached by App once it has them. */ |
| 78 | 78 | 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">; | |
| 80 | 80 | projectRootRef: Ref<string | null>; |
| 81 | 81 | activeFileRef: Ref<string>; |
| 82 | 82 | /** The active file's live text. */ |
| 83 | 83 | sourceRef: Ref<string>; |
| 84 | 84 | openProjectFile: OpenProjectFile; |
| 85 | 85 | 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; | |
| 88 | 86 | openPanel: () => void; |
| 89 | 87 | openHistoryEntry: (id: string) => void; |
| 90 | 88 | /** Save pending edits before files are restored; false when that failed. */ |
| @@ -550,8 +548,11 @@ export class AnnotationRounds { · 459 unmodified lines | ||
| 550 | 548 | await deps.reloadAfterRestore(); |
| 551 | 549 | }; |
| 552 | 550 | |
| 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"; | |
| 555 | 556 | |
| 556 | 557 | /** Where text selected on the PDF was written: SyncTeX's line, then the words around it. */ |
| 557 | 558 | mapPdfSelection = async (selection: PdfTextSelection): Promise<MappedPdfSelection> => { |
| @@ -563,12 +564,20 @@ export class AnnotationRounds { · 5 unmodified lines | ||
| 563 | 564 | return { path: target.path, line: source.slice(0, range.from).split("\n").length, from: range.from, to: range.to, source }; |
| 564 | 565 | }; |
| 565 | 566 | |
| 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 | + */ | |
| 567 | 573 | createFromPdf = (selection: PdfTextSelection, mapped: MappedPdfSelection, body: string, intent: AnnotationIntent | undefined, revise: boolean) => { |
| 568 | 574 | const deps = this.deps; |
| 569 | 575 | 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; | |
| 570 | 579 | 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, | |
| 572 | 581 | // eslint-disable-next-line lingui/no-unlocalized-strings -- stored sentinel; editorCommentAuthorDisplayName translates it |
| 573 | 582 | authorId: deps.author.id, authorName: deps.author.name.trim() || "Anonymous", |
| 574 | 583 | intent, pdf: { page: selection.page, rects: selection.rects, text: selection.text }, |
| @@ -8,7 +8,8 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type Dispatch, type ReactNode, type RefObject, type SetStateAction, |
| 9 | 9 | } from "react"; |
| 10 | 10 | 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"; | |
| 12 | 13 | import type { OpenProjectFile, ViewRestoreRequest } from "../app-types"; |
| 13 | 14 | import { lazyChunk } from "../lib/lazy-chunk"; |
| 14 | 15 | import { hadSuggestions, loadMarginReviewLayer, rememberSuggestions, subscribeMarginReviewFiles } from "../overleaf/margin-review-files"; |
| @@ -130,3 +130,33 @@ it("resolves and reopens from the panel with a toast whose undo puts it back", a · 129 unmodified lines | ||
| 130 | 130 | act(later); |
| 131 | 131 | expect(view.result.current.comments[0]?.resolved).toBe(false); |
| 132 | 132 | }); |
| 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 | +}); | |
| @@ -13,10 +13,19 @@ import { · 12 unmodified lines | ||
| 13 | 13 | import { publishMarginReviewComments } from "../overleaf/margin-review-files"; |
| 14 | 14 | import { showError } from "./notify"; |
| 15 | 15 | 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"; | |
| 17 | 18 | |
| 18 | 19 | type Ref<T> = { readonly current: T }; |
| 19 | 20 | |
| 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 | + | |
| 20 | 29 | /** The project's local annotations and the revision rounds that asked about them. */ |
| 21 | 30 | export type AnnotationsState = { annotations: EditorComment[]; rounds: AnnotationRound[] }; |
| 22 | 31 | |
| @@ -188,8 +197,24 @@ export function useEditorComments({ · 165 unmodified lines | ||
| 188 | 197 | * to the annotation as saved, or null when it became an Overleaf thread or |
| 189 | 198 | * could not be made. |
| 190 | 199 | */ |
| 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 | + | |
| 191 | 216 | 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); | |
| 193 | 218 | if (!overleafLink || !docId || !project) { |
| 194 | 219 | const annotation = { ...comment, number: nextAnnotationNumber(stateRef.current.annotations) }; |
| 195 | 220 | commit(withAnnotations((current) => [...current, annotation])); |
| @@ -204,7 +229,7 @@ export function useEditorComments({ · 8 unmodified lines | ||
| 204 | 229 | .create({ projectRoot: project.root, docId, path: comment.path }, comment.from, comment.quote, comment.body) |
| 205 | 230 | .catch((reason) => showError(toMessage(reason))); |
| 206 | 231 | return null; |
| 207 | − }, [overleafComments, overleafDocPaths, overleafLink, overleafRealtime.docId, overleafRealtime.liveFile, project, commit, t]); | |
| 232 | + }, [docIdOf, overleafComments, overleafLink, overleafRealtime.docId, overleafRealtime.liveFile, project, commit, t]); | |
| 208 | 233 | |
| 209 | 234 | const openPanel = useCallback(() => { |
| 210 | 235 | setPanelFocus(null); |
| @@ -290,7 +315,7 @@ export function useEditorComments({ · 79 unmodified lines | ||
| 290 | 315 | }, [agentOptionsRef, openSources, overleafComments.anchors, overleafComments.threads, overleafDocPaths, overleafLink, project, projectRootRef]); |
| 291 | 316 | |
| 292 | 317 | 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, | |
| 294 | 319 | deleteComment, panelOpen, openPanel, openReply, closePanel, panelFocus, panelFocusId, setPanelFocus, |
| 295 | 320 | activeId, setActiveId, focusRequest, focusHandled, |
| 296 | 321 | }; |
| @@ -4,7 +4,8 @@ import { invoke } from "@tauri-apps/api/core"; · 3 unmodified lines | ||
| 4 | 4 | import type { OverleafThread, ProjectSnapshot } from "../app-types"; |
| 5 | 5 | import { createOverleafConnectionStore } from "../overleaf/overleaf-connection-store"; |
| 6 | 6 | 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"; | |
| 8 | 9 | |
| 9 | 10 | vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); |
| 10 | 11 | vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => undefined) })); |
| @@ -26,11 +26,11 @@ import { · 25 unmodified lines | ||
| 26 | 26 | import { mentionsAny } from "../platform/ipc-error-phrases"; |
| 27 | 27 | import { useOverleafChat } from "../overleaf/use-overleaf-chat"; |
| 28 | 28 | 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"; | |
| 30 | 30 | 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"; | |
| 32 | 33 | import { type PresenceCursor } from "../overleaf/overleaf-editor-extensions"; |
| 33 | −import type { EditorComment } from "../editor/comments/editor-comment-data"; | |
| 34 | 34 | import { hasConflictMarkers } from "../history/conflict-markers"; |
| 35 | 35 | import { AUTO_COMMIT_MESSAGES } from "../history/version-messages"; |
| 36 | 36 | import type { |
| @@ -40,46 +40,6 @@ import type { · 3 unmodified lines | ||
| 40 | 40 | import { SYNC_OPERATION } from "../telemetry/app-log-export"; |
| 41 | 41 | import { overleafSyncWarnings } from "./overleaf-sync-warnings"; |
| 42 | 42 | |
| 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 | − | |
| 83 | 43 | type OverleafSyncOptions = { |
| 84 | 44 | auto?: boolean; |
| 85 | 45 | observedRemoteVersion?: number | null; |
| @@ -11,6 +11,9 @@ · 10 unmodified lines | ||
| 11 | 11 | */ |
| 12 | 12 | import { Suspense, useMemo, type Dispatch, type SetStateAction } from "react"; |
| 13 | 13 | 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"; | |
| 14 | 17 | import { SynaraLoadingSurface } from "../agent/synara-loading-surface"; |
| 15 | 18 | import { paperImportStageLabel } from "../papers/paper-import-progress"; |
| 16 | 19 | import type { NewEntryRequest, NewEntryType } from "../project/project-new-entries"; |
| @@ -20,9 +23,10 @@ import type { TrellisController } from "../trellis/trellis-controller"; · 3 unmodified lines | ||
| 20 | 23 | import { PanelErrorBoundary } from "../telemetry/panel-error-boundary"; |
| 21 | 24 | import { PanelActions } from "../trellis/trellis-panel-actions"; |
| 22 | 25 | import type { GitStatus, PaperSummary, ProjectSnapshot } from "../app-types"; |
| 26 | +import { toMessage } from "../app-utils"; | |
| 23 | 27 | import { PaperLibrary, ProjectFileTree, TrellisAgentSurface } from "./app-lazy-panels"; |
| 24 | 28 | import { useStableHandlers } from "./effect-helpers"; |
| 25 | −import { showError } from "./notify"; | |
| 29 | +import { showError, showNotice } from "./notify"; | |
| 26 | 30 | import { OpeningOverlay } from "./opening-overlay"; |
| 27 | 31 | import type { OpenDocuments } from "./use-open-documents"; |
| 28 | 32 | import type { usePaperActions } from "./use-paper-actions"; |
| @@ -55,7 +59,7 @@ export function usePanelPortals({ · 26 unmodified lines | ||
| 55 | 59 | newEntry: { request: NewEntryRequest | null; requestNew: (type: NewEntryType) => void }; |
| 56 | 60 | gitFiles: GitStatus["files"]; |
| 57 | 61 | 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">; | |
| 59 | 63 | overleafSyncing: boolean; |
| 60 | 64 | paperActions: ReturnType<typeof usePaperActions>; |
| 61 | 65 | referenceImport: ReferenceImport; |
| @@ -116,6 +120,14 @@ export function usePanelPortals({ · 54 unmodified lines | ||
| 116 | 120 | onImportAssets: tree.chooseAssets, |
| 117 | 121 | onImportFiles: (targetDirectory: string) => void tree.chooseFiles(targetDirectory), |
| 118 | 122 | 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 | + }, | |
| 119 | 131 | }); |
| 120 | 132 | const paperLibraryHandlers = useStableHandlers({ |
| 121 | 133 | onReveal: tree.revealItem, |
| @@ -158,6 +170,7 @@ export function usePanelPortals({ · 36 unmodified lines | ||
| 158 | 170 | activeFile={activeAsset || activePaper || (documents.tabsReady && !documents.tabs.includes(activeFile)) ? "" : activeFile} |
| 159 | 171 | activeAssetPath={activeAsset?.path ?? ""} |
| 160 | 172 | protectedPaths={protectedProjectPaths} |
| 173 | + mainDocument={project.manifest.rootDocuments.find((document) => document.isDefault && document.chosen)?.path ?? null} | |
| 161 | 174 | assetDropTarget={tree.assetDropTarget} |
| 162 | 175 | assetImporting={tree.assetImporting} |
| 163 | 176 | {...fileTreeHandlers} |
| @@ -49,4 +49,15 @@ describe("workspace restore", () => { · 48 unmodified lines | ||
| 49 | 49 | activeTab: "sections/intro.tex", |
| 50 | 50 | }); |
| 51 | 51 | }); |
| 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 | + }); | |
| 52 | 63 | }); |
| @@ -75,9 +75,9 @@ export function planWorkspaceRestore( · 74 unmodified lines | ||
| 75 | 75 | const paperKeys = new Set(papers.filter((paper) => paper.hasBlog || paper.hasFullText) |
| 76 | 76 | .map((paper) => paperTabKey(paper.arxivId))); |
| 77 | 77 | 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]; | |
| 81 | 81 | // In place of a Paper that can no longer be read, open the project's root |
| 82 | 82 | // document rather than whichever file happened to sit behind the Paper. |
| 83 | 83 | const unreadablePaper = Boolean(layout?.activeTab && isPaperTabKey(layout.activeTab) && !paperKeys.has(layout.activeTab)); |
| @@ -56,6 +56,8 @@ export function AnnotationForm(props: { · 55 unmodified lines | ||
| 56 | 56 | ready?: boolean; |
| 57 | 57 | /** The agent can be asked now; otherwise only Save is offered. */ |
| 58 | 58 | canRevise: boolean; |
| 59 | + /** An Overleaf comment carries words alone: no intents, and Save says where it goes. */ | |
| 60 | + overleaf?: boolean; | |
| 59 | 61 | onBodyChange: (body: string) => void; |
| 60 | 62 | onIntentChange: (intent: AnnotationIntent | undefined) => void; |
| 61 | 63 | onCancel: () => void; |
| @@ -64,7 +66,7 @@ export function AnnotationForm(props: { · 2 unmodified lines | ||
| 64 | 66 | const { t } = useLingui(); |
| 65 | 67 | const labels = useIntentLabels(); |
| 66 | 68 | 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)); | |
| 68 | 70 | return ( |
| 69 | 71 | <div |
| 70 | 72 | className="annotation-form" |
| @@ -91,7 +93,7 @@ export function AnnotationForm(props: { · 20 unmodified lines | ||
| 91 | 93 | value={props.body} |
| 92 | 94 | onChange={(event) => props.onBodyChange(event.target.value)} |
| 93 | 95 | /> |
| 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`}> | |
| 95 | 97 | {ANNOTATION_INTENTS.map((intent) => ( |
| 96 | 98 | <button |
| 97 | 99 | type="button" |
| @@ -103,11 +105,11 @@ export function AnnotationForm(props: { · 5 unmodified lines | ||
| 103 | 105 | {labels[intent]} |
| 104 | 106 | </button> |
| 105 | 107 | ))} |
| 106 | − </div> | |
| 108 | + </div>} | |
| 107 | 109 | {props.error && <p role="alert" className="annotation-form-error">{props.error}</p>} |
| 108 | 110 | <div className="annotation-form-actions"> |
| 109 | 111 | <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> | |
| 111 | 113 | </Button> |
| 112 | 114 | {props.canRevise && ( |
| 113 | 115 | <Button size="compact" variant="ghost" disabled={!saveable} onClick={() => props.onSave(true)}> |
| @@ -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 | +}); | |
| @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useLingui } from "@lingui/react/macro"; |
| 3 | 3 | import { hostErrorMessage } from "../app-utils"; |
| 4 | 4 | import type { AnnotationRounds, MappedPdfSelection } from "../app/annotation-rounds"; |
| 5 | +import type { CommentDestination } from "../app/use-editor-comments"; | |
| 5 | 6 | import { useExitPicture } from "../components/ui/exit-picture"; |
| 6 | 7 | import { popupMotionClassName } from "../components/ui/popup-motion"; |
| 7 | 8 | import { LoadingAnnouncement } from "../components/ui/loading-status"; |
| @@ -37,6 +38,10 @@ export function PdfAnnotationComposer(props: { · 29 unmodified lines | ||
| 37 | 38 | const [error, setError] = useState<string | null>(null); |
| 38 | 39 | const [body, setBody] = useState(props.initial?.body ?? ""); |
| 39 | 40 | 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); | |
| 40 | 45 | const { rounds } = props; |
| 41 | 46 | |
| 42 | 47 | useEffect(() => { |
| @@ -59,6 +64,16 @@ export function PdfAnnotationComposer(props: { · 16 unmodified lines | ||
| 59 | 64 | |
| 60 | 65 | const name = mapped ? baseName(mapped.path) : null; |
| 61 | 66 | 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; | |
| 62 | 77 | return ( |
| 63 | 78 | <div |
| 64 | 79 | ref={popupRef} |
| @@ -73,14 +88,20 @@ export function PdfAnnotationComposer(props: { · 8 unmodified lines | ||
| 73 | 88 | location={location} |
| 74 | 89 | body={body} |
| 75 | 90 | 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} | |
| 79 | 95 | onBodyChange={setBody} |
| 80 | 96 | onIntentChange={setIntent} |
| 81 | 97 | onCancel={() => props.onDismiss({ body, intent })} |
| 82 | 98 | onSave={(revise) => { |
| 83 | 99 | if (!mapped) return; |
| 100 | + const destinationNow = rounds.pdfNoteDestination(mapped.path); | |
| 101 | + if (destinationNow === "other-file" || destinationNow === "offline") { | |
| 102 | + setDestinationAtSave(destinationNow); | |
| 103 | + return; | |
| 104 | + } | |
| 84 | 105 | if (rounds.createFromPdf(selection, mapped, body, intent, revise)) props.onDone(); |
| 85 | 106 | else setError(t`The source changed since the PDF was built. Build again, then annotate.`); |
| 86 | 107 | }} |
| @@ -834,6 +834,10 @@ msgstr "{path} is not a folder." · 833 unmodified lines | ||
| 834 | 834 | msgid "{path} is not a UTF-8 path." |
| 835 | 835 | msgstr "{path} is not a UTF-8 path." |
| 836 | 836 | |
| 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 | + | |
| 837 | 841 | #: src/app/vanished-file.ts |
| 838 | 842 | msgid "{path} was deleted" |
| 839 | 843 | msgstr "{path} was deleted" |
| @@ -846,6 +850,10 @@ msgstr "{path} was moved to {moved}" · 6 unmodified lines | ||
| 846 | 850 | msgid "{path}, line {line}" |
| 847 | 851 | msgstr "{path}, line {line}" |
| 848 | 852 | |
| 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 | + | |
| 849 | 857 | #. placeholder {0}: peers.length |
| 850 | 858 | #: src/papers/bibliography-audit.tsx |
| 851 | 859 | msgid "{position} of {0}" |
| @@ -2587,6 +2595,10 @@ msgstr "Build log copied" · 1735 unmodified lines | ||
| 2587 | 2595 | msgid "Build notes" |
| 2588 | 2596 | msgstr "Build notes" |
| 2589 | 2597 | |
| 2598 | +#: src/app/use-panel-portals.tsx | |
| 2599 | +msgid "Build now compiles {path}." | |
| 2600 | +msgstr "Build now compiles {path}." | |
| 2601 | + | |
| 2590 | 2602 | #: src/build/compile-diagnostics-panel.tsx |
| 2591 | 2603 | msgid "Build output" |
| 2592 | 2604 | msgstr "Build output" |
| @@ -3721,6 +3733,10 @@ msgstr "Comment from {opener}" · 1128 unmodified lines | ||
| 3721 | 3733 | msgid "Comment from {opener}, {0} messages" |
| 3722 | 3734 | msgstr "Comment from {opener}, {0} messages" |
| 3723 | 3735 | |
| 3736 | +#: src/canvas/annotation-composer.tsx | |
| 3737 | +msgid "Comment on Overleaf" | |
| 3738 | +msgstr "Comment on Overleaf" | |
| 3739 | + | |
| 3724 | 3740 | #: src/editor/latex/latex-selection-toolbar.tsx |
| 3725 | 3741 | msgid "Comment on selected Markdown" |
| 3726 | 3742 | msgstr "Comment on selected Markdown" |
| @@ -10182,6 +10198,10 @@ msgstr "Overleaf sync is finishing. Try switching projects again in a moment." · 6455 unmodified lines | ||
| 10182 | 10198 | msgid "Overleaf sync paused" |
| 10183 | 10199 | msgstr "Overleaf sync paused" |
| 10184 | 10200 | |
| 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 | + | |
| 10185 | 10205 | #: src/platform/host-errors-overleaf.ts |
| 10186 | 10206 | msgid "Overleaf turned this request away, though the session is still valid." |
| 10187 | 10207 | msgstr "Overleaf turned this request away, though the session is still valid." |
| @@ -12806,6 +12826,10 @@ msgctxt "macOS menu bar" · 2618 unmodified lines | ||
| 12806 | 12826 | msgid "Services" |
| 12807 | 12827 | msgstr "Services" |
| 12808 | 12828 | |
| 12829 | +#: src/project/project-file-tree.tsx | |
| 12830 | +msgid "Set as main document" | |
| 12831 | +msgstr "Set as main document" | |
| 12832 | + | |
| 12809 | 12833 | #: src/settings/agent-environment-settings.tsx |
| 12810 | 12834 | msgid "Set HTTPS_PROXY or ANTHROPIC_BASE_URL in your shell startup file, or turn on a proxy in macOS, then restart the agent." |
| 12811 | 12835 | 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 | ||
| 14812 | 14836 | msgid "This comment is no longer attached to any text, so Overleaf has nowhere to apply this." |
| 14813 | 14837 | msgstr "This comment is no longer attached to any text, so Overleaf has nowhere to apply this." |
| 14814 | 14838 | |
| 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 | + | |
| 14815 | 14843 | #: src/history/conflict-resolver.tsx |
| 14816 | 14844 | msgid "This computer" |
| 14817 | 14845 | msgstr "This computer" |
| @@ -834,6 +834,10 @@ msgstr "{path} 不是文件夹" · 833 unmodified lines | ||
| 834 | 834 | msgid "{path} is not a UTF-8 path." |
| 835 | 835 | msgstr "{path} 不是 UTF-8 路径" |
| 836 | 836 | |
| 837 | +#: src/platform/host-errors-workspace.ts | |
| 838 | +msgid "{path} is not in the project folder." | |
| 839 | +msgstr "{path} 不在项目文件夹里" | |
| 840 | + | |
| 837 | 841 | #: src/app/vanished-file.ts |
| 838 | 842 | msgid "{path} was deleted" |
| 839 | 843 | msgstr "{path} 已被删除" |
| @@ -846,6 +850,10 @@ msgstr "{path} 已被移到 {moved}" · 6 unmodified lines | ||
| 846 | 850 | msgid "{path}, line {line}" |
| 847 | 851 | msgstr "{path} 第 {line} 行" |
| 848 | 852 | |
| 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 | + | |
| 849 | 857 | #. placeholder {0}: peers.length |
| 850 | 858 | #: src/papers/bibliography-audit.tsx |
| 851 | 859 | msgid "{position} of {0}" |
| @@ -2587,6 +2595,10 @@ msgstr "已复制编译日志" · 1735 unmodified lines | ||
| 2587 | 2595 | msgid "Build notes" |
| 2588 | 2596 | msgstr "提示" |
| 2589 | 2597 | |
| 2598 | +#: src/app/use-panel-portals.tsx | |
| 2599 | +msgid "Build now compiles {path}." | |
| 2600 | +msgstr "现在编译 {path}" | |
| 2601 | + | |
| 2590 | 2602 | #: src/build/compile-diagnostics-panel.tsx |
| 2591 | 2603 | msgid "Build output" |
| 2592 | 2604 | msgstr "编译结果" |
| @@ -3721,6 +3733,10 @@ msgstr "{opener} 的评论" · 1128 unmodified lines | ||
| 3721 | 3733 | msgid "Comment from {opener}, {0} messages" |
| 3722 | 3734 | msgstr "{opener} 的评论,共 {0} 条消息" |
| 3723 | 3735 | |
| 3736 | +#: src/canvas/annotation-composer.tsx | |
| 3737 | +msgid "Comment on Overleaf" | |
| 3738 | +msgstr "评论到 Overleaf" | |
| 3739 | + | |
| 3724 | 3740 | #: src/editor/latex/latex-selection-toolbar.tsx |
| 3725 | 3741 | msgid "Comment on selected Markdown" |
| 3726 | 3742 | msgstr "评论所选" |
| @@ -10182,6 +10198,10 @@ msgstr "Overleaf 还在同步,请稍后再切换" · 6455 unmodified lines | ||
| 10182 | 10198 | msgid "Overleaf sync paused" |
| 10183 | 10199 | msgstr "同步已暂停" |
| 10184 | 10200 | |
| 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 | + | |
| 10185 | 10205 | #: src/platform/host-errors-overleaf.ts |
| 10186 | 10206 | msgid "Overleaf turned this request away, though the session is still valid." |
| 10187 | 10207 | msgstr "会话仍然有效,但 Overleaf 拒绝了这次请求" |
| @@ -12806,6 +12826,10 @@ msgctxt "macOS menu bar" · 2618 unmodified lines | ||
| 12806 | 12826 | msgid "Services" |
| 12807 | 12827 | msgstr "服务" |
| 12808 | 12828 | |
| 12829 | +#: src/project/project-file-tree.tsx | |
| 12830 | +msgid "Set as main document" | |
| 12831 | +msgstr "设为主文档" | |
| 12832 | + | |
| 12809 | 12833 | #: src/settings/agent-environment-settings.tsx |
| 12810 | 12834 | msgid "Set HTTPS_PROXY or ANTHROPIC_BASE_URL in your shell startup file, or turn on a proxy in macOS, then restart the agent." |
| 12811 | 12835 | msgstr "在 shell 启动文件中设置 HTTPS_PROXY 或 ANTHROPIC_BASE_URL,或在 macOS 中开启代理,然后重启 AI 助手。" |
| @@ -14812,6 +14836,10 @@ msgstr "还没写内容" · 2000 unmodified lines | ||
| 14812 | 14836 | msgid "This comment is no longer attached to any text, so Overleaf has nowhere to apply this." |
| 14813 | 14837 | msgstr "这条评论已没有对应文字,无法操作" |
| 14814 | 14838 | |
| 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 | + | |
| 14815 | 14843 | #: src/history/conflict-resolver.tsx |
| 14816 | 14844 | msgid "This computer" |
| 14817 | 14845 | msgstr "本机" |
| @@ -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 | +} | |
| @@ -37,6 +37,10 @@ describe("hostErrorText", () => { · 36 unmodified lines | ||
| 37 | 37 | expect(hostErrorText(english)).toMatch(/\p{Script=Han}/u); |
| 38 | 38 | } |
| 39 | 39 | 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 文件,选择“设为主文档”。"); | |
| 40 | 44 | expect(hostErrorText("Keep the owner to at most 80 characters.")).toBe("负责人最多 80 个字符"); |
| 41 | 45 | }); |
| 42 | 46 | |
| @@ -55,7 +55,7 @@ export const SETUP_HOST_ERRORS: readonly HostErrorEntry[] = [ · 54 unmodified lines | ||
| 55 | 55 | |
| 56 | 56 | // The document a build compiles (latex/build.rs names its argument; |
| 57 | 57 | // 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.`], | |
| 59 | 59 | ["latex/pinned.rs", "Root document not found: {}", ([path]) => t`Root document not found: ${path}`], |
| 60 | 60 | |
| 61 | 61 | // The built-in agent's startup (synara.rs). The startup log is the |
| @@ -38,6 +38,8 @@ export const WORKSPACE_HOST_ERRORS: readonly HostErrorEntry[] = [ · 37 unmodified lines | ||
| 38 | 38 | ["project/index_sources.rs", "Could not read {}: {message}", ([path, error]) => t`Could not read ${path}: ${error}`], |
| 39 | 39 | ["overleaf/files.rs", "Could not write {rel}: {error}", ([path, error]) => t`Could not write ${path}: ${error}`], |
| 40 | 40 | ["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.`], | |
| 41 | 43 | |
| 42 | 44 | // The window's project (ipc.rs) |
| 43 | 45 | ["ipc.rs", "Open a project first.", () => t`Open a project first.`], |
| @@ -38,6 +38,8 @@ function baseProps(): ProjectFileTreeProps { · 37 unmodified lines | ||
| 38 | 38 | activeFile: "main.tex", |
| 39 | 39 | activeAssetPath: "", |
| 40 | 40 | protectedPaths: [], |
| 41 | + mainDocument: "main.tex", | |
| 42 | + onSetMainDocument: vi.fn(), | |
| 41 | 43 | onFile: vi.fn(), |
| 42 | 44 | onAsset: vi.fn(), |
| 43 | 45 | onBeginFigureDrag: vi.fn(), |
| @@ -285,6 +287,36 @@ describe("ProjectFileTree", () => { · 241 unmodified lines | ||
| 285 | 287 | await waitFor(() => expect(view.props.onDeleteEntries).toHaveBeenCalledWith(["main.tex", "sections/intro.tex"])); |
| 286 | 288 | }); |
| 287 | 289 | |
| 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 | + | |
| 288 | 320 | it("keeps a pressed row from scrolling itself into view, so a row cut off at the edge still opens", async () => { |
| 289 | 321 | expandSections(); |
| 290 | 322 | const view = renderTree(); |
| @@ -4,7 +4,9 @@ import { useLingui } from "@lingui/react/macro"; · 3 unmodified lines | ||
| 4 | 4 | import { writeText } from "@tauri-apps/plugin-clipboard-manager"; |
| 5 | 5 | import type { ContextMenuItem as PierreContextMenuItem, ContextMenuOpenContext, FileTreeDropTarget } from "@pierre/trees"; |
| 6 | 6 | 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"; | |
| 8 | 10 | import { ContextMenu, ContextMenuTrigger } from "../components/ui/context-menu"; |
| 9 | 11 | import { ExternalScrollbar } from "../components/ui/external-scrollbar"; |
| 10 | 12 | import { absoluteProjectPath } from "../app-utils"; |
| @@ -52,6 +54,12 @@ export type ProjectFileTreeProps = { · 41 unmodified lines | ||
| 52 | 54 | activeFile: string; |
| 53 | 55 | activeAssetPath: string; |
| 54 | 56 | 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; | |
| 55 | 63 | onFile: (path: string) => void; |
| 56 | 64 | onLikelyFile?: (path: string) => void; |
| 57 | 65 | onAsset: (path: string) => void; |
| @@ -360,6 +368,13 @@ export function ProjectFileTree(props: ProjectFileTreeProps) { · 302 unmodified lines | ||
| 360 | 368 | { icon: ImagePlus, label: t`Import images here`, disabled: props.assetImporting, run: () => props.onImportAssets(path) }, |
| 361 | 369 | { icon: ClipboardPaste, label: t`Paste clipboard image as figure`, run: () => props.onPasteImage(path) }, |
| 362 | 370 | ] : []), |
| 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 | + }] : []), | |
| 363 | 378 | { icon: Pencil, label: t`Rename`, group: true, run: () => model.startRenaming(item.path) }, |
| 364 | 379 | { |
| 365 | 380 | icon: Copy, |