//! Text editor: 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). //! //! The editor fills the whole content area; its title, Save button and close //! action live in the top bar (see `views::file_view`). The editor //! registers its save callback via `register_save` and reports its //! dirty/saving state through the shared signals. use leptos::prelude::*; use wasm_bindgen::closure::Closure; use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::spawn_local; use crate::api::{self, ApiError}; use crate::cm; use crate::components::icon::icon_svg; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show}; use crate::i18n; use crate::icons::IconName; use crate::views::dialogs::ConfirmDialog; use crate::views::file_view::FileView; /// The full-page editor (no window chrome: the top bar carries the title, /// save and close). #[component] pub fn Editor( target: FileView, /// The editor stores its save callback here (top-bar Save button); /// cleared on unmount. Absent in read-only mode. register_save: WriteSignal>>, /// The top bar's X button calls the registered close request (which /// asks about unsaved changes first); cleared on unmount. register_close: WriteSignal>>, dirty: ReadSignal, set_dirty: WriteSignal, set_saving: WriteSignal, close: Callback<()>, toast: ToastMsg, /// Re-fetch the file list after a successful save. refresh: Callback<()>, ) -> impl IntoView { let node = NodeRef::::new(); let (status, set_status) = signal(Option::::None); 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); // The change-listener closure backing the editable CodeMirror. CodeMirror // holds a JS reference to it for the view's lifetime, so we keep the Rust // side here (rather than `forget()`-ing it) and free it in the cleanup // below — after `cm::destroy` releases CodeMirror's reference. let cm_change = 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 ro = target.readonly; // Load the file and create an editable CodeMirror. { let t = target.clone(); 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)) => { if ro { match cm::create(&el, &text, &t.name, false, None) { Ok(v) => cm_view.set_value(Some(v)), Err(e) => set_status.set(Some(e)), } return; } 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; } set_dirty.set(true); }); // Borrow the JS function while `closure` is alive; the // synchronous create() only needs it for that call. let js_ref = closure.as_ref().unchecked_ref(); match cm::create(&el, &text, &t.name, true, Some(js_ref)) { Ok(v) => { let _ = cm::focus(&v); cm_view.set_value(Some(v)); cm_change.set_value(Some(closure)); loaded_mtime.set_value(mtime); } Err(e) => set_status.set(Some(e)), } // On failure the closure is simply dropped here; on // success it is owned by `cm_change` until cleanup. } Err(e) => set_status.set(Some(e.to_string())), } }); }); } // Destroy the CodeMirror view when the editor closes. Order matters: // `destroy` must release CodeMirror's JS reference before we drop the // Rust-side owner of the change-listener closure. on_cleanup(move || { if let Some(v) = cm_view.try_update_value(Option::take).flatten() { let _ = cm::destroy(&v); } cm_change.try_update_value(Option::take); }); // Save and overwrite differ only in the conflict check (`force`). let make_save = |force: bool| { let t = target.clone(); Callback::new(move |_| { let t = t.clone(); spawn_local(async move { 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(t.root_id, &t.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, i18n::t(i18n::k::SAVED)); refresh.run(()); } Err(ApiError::Conflict) => set_conflict.set(true), Err(e) => set_status.set(Some(e.to_string())), } set_saving.set(false); }); }) }; let save_cb: Callback<()> = make_save(false); let overwrite_cb: Callback<()> = make_save(true); // Expose the save callback to the top bar (read-only mode has none). Effect::new(move |_| { register_save.set(if ro { None } else { Some(save_cb) }); }); on_cleanup(move || register_save.set(None)); // Load the latest on-disk version (discards local edits). let load_latest_cb: Callback<()> = { let t = target.clone(); Callback::new(move |_| { let t = t.clone(); spawn_local(async move { set_status.set(None); match api::fetch_content_meta(t.root_id, &t.path).await { Ok((text, mtime)) => { cm_view.with_value(|v| { if let Some(v) = v.as_ref() { suppress.set_value(true); let _ = cm::set_value(v, &text); } }); loaded_mtime.set_value(mtime); set_dirty.set(false); set_conflict.set(false); } Err(e) => set_status.set(Some(e.to_string())), } }); }) }; // Close, prompting first if there are unsaved changes. let request_close = Callback::new(move |_| { if dirty.get() { set_confirm_discard.set(true); } else { close.run(()); } }); // Expose the guarded close request to the top bar's X button. Effect::new(move |_| { register_close.set(Some(request_close)); }); on_cleanup(move || register_close.set(None)); // Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save // in read-only mode. if !ro { 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(); save_cb.run(()); } }, ); } // Escape: closes the unsaved-changes prompt if open, otherwise the error // card if shown, otherwise closes the editor (with the dirty guard). The // conflict dialog is left alone — it requires an explicit choice. crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { if ev.key() != "Escape" { return; } if confirm_discard.get() { set_confirm_discard.set(false); } else if status.get().is_some() { set_status.set(None); } else if !conflict.get() { request_close.run(()); } }); view! {
{move || { status.get().map(|e| view! {
{e}
}) }} // Conflict: the file changed on disk since it was opened. // Closing it means "keep my version, decide later", so Escape and // a backdrop click leave the editor open and dirty. // Unsaved-changes confirmation. Dismissing it is "keep editing": // the destructive choice needs the button.
} }