//! 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 leptos::prelude::*; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::spawn_local; use web_sys::MouseEvent; use crate::api::{self, ApiError}; use crate::cm; use crate::components::icon::Icon; use crate::components::toast::{ToastMsg, show}; use crate::icons::IconName; /// 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). #[allow(clippy::too_many_arguments)] // long signal/JS-handle list async fn do_save( target: EditTarget, force: bool, cm_view: StoredValue, LocalStorage>, loaded_mtime: StoredValue>, set_dirty: WriteSignal, set_saving: WriteSignal, set_status: WriteSignal>, set_conflict: WriteSignal, toast: ToastMsg, refresh: Callback<()>, ) { let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value)); let Some(text) = text else { set_status.set(Some("editor not ready".to_string())); return; }; let expected = loaded_mtime.get_value(); set_saving.set(true); set_status.set(None); match api::save_content(target.root_id, &target.path, &text, expected, force).await { Ok(new_mtime) => { loaded_mtime.set_value(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 = StoredValue::new(Option::::None); // The live CodeMirror view (destroyed on close). let cm_view = StoredValue::new_local(Option::::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 = StoredValue::new(false); let name = target.name.clone(); // Load the file and create an editable CodeMirror. { let t = target.clone(); let st = set_status; 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 |_| { // Consume the suppression flag: only the change // right after a programmatic set_value is ignored. if suppress.get_value() { suppress.set_value(false); 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); cm_view.set_value(Some(c.view.clone())); loaded_mtime.set_value(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. on_cleanup(move || { if let Some(v) = cm_view.try_update_value(Option::take).flatten() { cm::destroy(&v); } }); // Save and overwrite differ only in the conflict check (`force`). let make_save = |force: bool| { let t = target.clone(); let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict); Callback::new(move |_| { let t2 = t.clone(); spawn_local(async move { do_save( t2, force, cm_view, loaded_mtime, sd, ss, st, sc, toast, refresh, ) .await; }); }) }; let save_cb: Callback<()> = make_save(false); let overwrite_cb: Callback<()> = make_save(true); // Load the latest on-disk version (discards local edits). let load_latest_cb: Callback<()> = { let t = target.clone(); let (sd, st, sc) = (set_dirty, set_status, set_conflict); Callback::new(move |_| { let t2 = t.clone(); spawn_local(async move { st.set(None); match api::fetch_content_meta(t2.root_id, &t2.path).await { Ok((text, mtime)) => { cm_view.with_value(|v| { if let Some(v) = v.as_ref() { suppress.set_value(true); cm::set_value(v, &text); } }); loaded_mtime.set_value(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 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; crate::util::owned_window_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; crate::util::owned_window_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! { } }