//! Text editor (milestone 5): an editable CodeMirror with save, a dirty-state //! indicator, Ctrl/Cmd+S, and save-time conflict handling (the file changed on //! disk since it was opened). use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use leptos::prelude::*; use wasm_bindgen::closure::Closure; use wasm_bindgen::JsValue; use wasm_bindgen_futures::spawn_local; use web_sys::MouseEvent; use crate::api::{self, ApiError}; use crate::cm; use crate::components::toast::{show, ToastMsg}; /// A text file the user wants to edit. #[derive(Clone)] pub struct EditTarget { pub root_id: i64, /// Path relative to the root (e.g. "docs/a.txt"). pub path: String, /// Display name (used for the title + language detection). pub name: String, } /// Read the current document and PUT it, updating the editor's state. /// /// `force` skips the server's conflict check (used by the "Overwrite" button). async fn do_save( target: EditTarget, force: bool, cm_view: &Arc>>, loaded_mtime: &Arc>>, set_dirty: WriteSignal, set_saving: WriteSignal, set_status: WriteSignal>, set_conflict: WriteSignal, toast: ToastMsg, refresh: Callback<()>, ) { let text = match cm_view.lock() { Ok(g) => g.as_ref().and_then(|v| cm::get_value(v)), Err(_) => None, }; let Some(text) = text else { set_status.set(Some("editor not ready".to_string())); return; }; let expected = loaded_mtime.lock().ok().and_then(|g| *g); set_saving.set(true); set_status.set(None); match api::save_content(target.root_id, &target.path, &text, expected, force).await { Ok(new_mtime) => { if let Ok(mut g) = loaded_mtime.lock() { *g = Some(new_mtime); } set_dirty.set(false); set_conflict.set(false); show(toast, "Saved"); refresh.run(()); } Err(ApiError::Conflict) => { set_conflict.set(true); } Err(e) => { set_status.set(Some(e.to_string())); } } set_saving.set(false); } /// The editor overlay. #[component] pub fn EditorModal( target: EditTarget, close: Callback<()>, toast: ToastMsg, refresh: Callback<()>, ) -> impl IntoView { let node = NodeRef::::new(); let (status, set_status) = signal(Option::::None); let (dirty, set_dirty) = signal(false); let (saving, set_saving) = signal(false); let (conflict, set_conflict) = signal(false); let (confirm_discard, set_confirm_discard) = signal(false); // The mtime the loaded content was anchored to (for the conflict check). let loaded_mtime: Arc>> = Arc::new(Mutex::new(None)); // The live CodeMirror view (destroyed on close). let cm_view: Arc>> = Arc::new(Mutex::new(None)); // Set before a programmatic `set_value` so the resulting doc change is not // counted as a user edit (keeps the dirty state honest). let suppress = Arc::new(AtomicBool::new(false)); let name = target.name.clone(); // Load the file and create an editable CodeMirror. { let t = target.clone(); let st = set_status; let h = cm_view.clone(); let sup = suppress.clone(); let lt = loaded_mtime.clone(); let sd = set_dirty; node.on_load(move |el: web_sys::HtmlDivElement| { spawn_local(async move { match api::fetch_content_meta(t.root_id, &t.path).await { Ok((text, mtime)) => { let closure = Closure::::new(move |_| { if sup.swap(false, Ordering::SeqCst) { return; } sd.set(true); }); // Borrow the JS function while `closure` is alive; the // synchronous create() only needs it for that call. let js_ref: &JsValue = closure.as_ref(); match cm::create(&el, &text, &t.name, true, Some(js_ref)) { Ok(c) => { cm::focus(&c.view); let _ = h.lock().map(|mut g| *g = Some(c.view.clone())); let _ = lt.lock().map(|mut g| *g = mtime); } Err(e) => st.set(Some(e)), } // Keep the Rust callback alive for the editor's lifetime // (the CM view holds the JS reference; a small, bounded // leak per open, same pattern as the upload picker). closure.forget(); } Err(e) => st.set(Some(e.to_string())), } }); }); } // Destroy the CodeMirror view when the editor closes. { let h = cm_view.clone(); on_cleanup(move || { if let Ok(mut g) = h.lock() { if let Some(v) = g.take() { cm::destroy(&v); } } }); } // Save (with the conflict check). let save_cb: Callback<()> = { let t = target.clone(); let cmv = cm_view.clone(); let lt = loaded_mtime.clone(); let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict); let (toast, refresh) = (toast, refresh); Callback::new(move |_| { let t2 = t.clone(); let cmv2 = cmv.clone(); let lt2 = lt.clone(); let refresh2 = refresh.clone(); spawn_local(async move { do_save(t2, false, &cmv2, <2, sd, ss, st, sc, toast, refresh2).await; }); }) }; // Overwrite (skip the conflict check). let overwrite_cb: Callback<()> = { let t = target.clone(); let cmv = cm_view.clone(); let lt = loaded_mtime.clone(); let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict); let (toast, refresh) = (toast, refresh); Callback::new(move |_| { let t2 = t.clone(); let cmv2 = cmv.clone(); let lt2 = lt.clone(); let refresh2 = refresh.clone(); spawn_local(async move { do_save(t2, true, &cmv2, <2, sd, ss, st, sc, toast, refresh2).await; }); }) }; // Load the latest on-disk version (discards local edits). let load_latest_cb: Callback<()> = { let t = target.clone(); let cmv = cm_view.clone(); let lt = loaded_mtime.clone(); let sup = suppress.clone(); let (sd, st, sc) = (set_dirty, set_status, set_conflict); Callback::new(move |_| { let t2 = t.clone(); let cmv2 = cmv.clone(); let lt2 = lt.clone(); let sup2 = sup.clone(); spawn_local(async move { st.set(None); match api::fetch_content_meta(t2.root_id, &t2.path).await { Ok((text, mtime)) => { if let Ok(g) = cmv2.lock() { if let Some(v) = g.as_ref() { sup2.store(true, Ordering::SeqCst); cm::set_value(v, &text); } } if let Ok(mut g) = lt2.lock() { *g = mtime; } sd.set(false); sc.set(false); } Err(e) => st.set(Some(e.to_string())), } }); }) }; // Close, prompting first if there are unsaved changes. let request_close: Callback<()> = { let close = close.clone(); let d = dirty; let scd = set_confirm_discard; Callback::new(move |_| { if d.get() { scd.set(true); } else { close.run(()); } }) }; // Ctrl/Cmd+S saves (scoped to the editor's lifetime). { let cb = save_cb.clone(); let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") { ev.prevent_default(); cb.run(()); } }); } // Escape: closes the unsaved-changes prompt if open, otherwise closes the // editor (with the dirty guard). The conflict dialog is left alone — it // requires an explicit choice. { let cd = confirm_discard; let scd = set_confirm_discard; let cf = conflict; let rc = request_close.clone(); let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() != "Escape" { return; } if cd.get() { scd.set(false); } else if !cf.get() { rc.run(()); } }); } view! { } }