//! 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::JsValue; use wasm_bindgen::closure::Closure; use wasm_bindgen_futures::spawn_local; use crate::api::{self, ApiError}; use crate::cm; use crate::components::icon::Icon; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show}; use crate::i18n; 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, /// True in read-only folders: the file is shown, not editable. pub readonly: bool, } /// 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, 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); } /// The full-page editor (no window chrome: the top bar carries the title, /// save and close). #[component] pub fn Editor( target: EditTarget, /// 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(); 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)) => { if ro { match cm::create(&el, &text, &t.name, false, None) { Ok(c) => cm_view.set_value(Some(c.view.clone())), Err(e) => st.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; } 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())); cm_change.set_value(Some(closure)); loaded_mtime.set_value(mtime); } Err(e) => st.set(Some(e)), } // On failure the closure is simply dropped here; on // success it is owned by `cm_change` until cleanup. } Err(e) => st.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() { 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(); 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); // Expose the save callback to the top bar (read-only mode has none). { let rs = register_save; let cb = save_cb; Effect::new(move |_| { rs.set(if ro { None } else { Some(cb) }); }); on_cleanup(move || rs.set(None)); } // 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(()); } }) }; // Expose the guarded close request to the top bar's X button. { let rc = request_close; let sig = register_close; Effect::new(move |_| { sig.set(Some(rc)); }); on_cleanup(move || sig.set(None)); } // Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save // in read-only mode. if !ro { 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 the error // card if shown, 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 st = status; let stx = set_status; 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 st.get().is_some() { stx.set(None); } else if !cf.get() { rc.run(()); } }, ); } view! {
{move || { let err = status.get(); match err { Some(e) => view! {
{e}
} .into_view() .into_any(), None => { view! {}.into_any() }, } }} // 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. {move || { if conflict.get() { view! { } .into_view() .into_any() } else { view! {}.into_any() } }} // Unsaved-changes confirmation. Dismissing it is "keep editing": // the destructive choice needs the button. {move || { if confirm_discard.get() { view! { } .into_view() .into_any() } else { view! {}.into_any() } }}
} }