editor.rs
⎇
Raw
1//! Text editor: an editable CodeMirror with save, a dirty-state indicator,
2//! Ctrl/Cmd+S, and save-time conflict handling (the file changed on disk
3//! since it was opened).
4//!
5//! The editor fills the whole content area; its title, Save button and close
6//! action live in the top bar (see `views::file_view`). The editor
7//! registers its save callback via `register_save` and reports its
8//! dirty/saving state through the shared signals.
9
10use leptos::prelude::*;
11use wasm_bindgen::closure::Closure;
12use wasm_bindgen::{JsCast, JsValue};
13use wasm_bindgen_futures::spawn_local;
14
15use crate::api::{self, ApiError};
16use crate::cm;
17use crate::components::icon::icon_svg;
18use crate::components::modal::Modal;
19use crate::components::toast::{ToastMsg, show};
20use crate::i18n;
21use crate::icons::IconName;
22use crate::views::dialogs::ConfirmDialog;
23use crate::views::file_view::FileView;
24
25/// The full-page editor (no window chrome: the top bar carries the title,
26/// save and close).
27#[component]
28pub fn Editor(
29 target: FileView,
30 /// The editor stores its save callback here (top-bar Save button);
31 /// cleared on unmount. Absent in read-only mode.
32 register_save: WriteSignal<Option<Callback<()>>>,
33 /// The top bar's X button calls the registered close request (which
34 /// asks about unsaved changes first); cleared on unmount.
35 register_close: WriteSignal<Option<Callback<()>>>,
36 dirty: ReadSignal<bool>,
37 set_dirty: WriteSignal<bool>,
38 set_saving: WriteSignal<bool>,
39 close: Callback<()>,
40 toast: ToastMsg,
41 /// Re-fetch the file list after a successful save.
42 refresh: Callback<()>,
43) -> impl IntoView {
44 let node = NodeRef::<leptos::html::Div>::new();
45 let (status, set_status) = signal(Option::<String>::None);
46 let (conflict, set_conflict) = signal(false);
47 let (confirm_discard, set_confirm_discard) = signal(false);
48 // The mtime the loaded content was anchored to (for the conflict check).
49 let loaded_mtime = StoredValue::new(Option::<i64>::None);
50 // The live CodeMirror view (destroyed on close).
51 let cm_view = StoredValue::new_local(Option::<JsValue>::None);
52 // The change-listener closure backing the editable CodeMirror. CodeMirror
53 // holds a JS reference to it for the view's lifetime, so we keep the Rust
54 // side here (rather than `forget()`-ing it) and free it in the cleanup
55 // below — after `cm::destroy` releases CodeMirror's reference.
56 let cm_change = StoredValue::new_local(Option::<Closure<dyn FnMut(JsValue)>>::None);
57 // Set before a programmatic `set_value` so the resulting doc change is not
58 // counted as a user edit (keeps the dirty state honest).
59 let suppress = StoredValue::new(false);
60
61 let ro = target.readonly;
62
63 // Load the file and create an editable CodeMirror.
64 {
65 let t = target.clone();
66 node.on_load(move |el: web_sys::HtmlDivElement| {
67 spawn_local(async move {
68 match api::fetch_content_meta(t.root_id, &t.path).await {
69 Ok((text, mtime)) => {
70 if ro {
71 match cm::create(&el, &text, &t.name, false, None) {
72 Ok(v) => cm_view.set_value(Some(v)),
73 Err(e) => set_status.set(Some(e)),
74 }
75 return;
76 }
77 let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
78 // Consume the suppression flag: only the change
79 // right after a programmatic set_value is ignored.
80 if suppress.get_value() {
81 suppress.set_value(false);
82 return;
83 }
84 set_dirty.set(true);
85 });
86 // Borrow the JS function while `closure` is alive; the
87 // synchronous create() only needs it for that call.
88 let js_ref = closure.as_ref().unchecked_ref();
89 match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
90 Ok(v) => {
91 let _ = cm::focus(&v);
92 cm_view.set_value(Some(v));
93 cm_change.set_value(Some(closure));
94 loaded_mtime.set_value(mtime);
95 }
96 Err(e) => set_status.set(Some(e)),
97 }
98 // On failure the closure is simply dropped here; on
99 // success it is owned by `cm_change` until cleanup.
100 }
101 Err(e) => set_status.set(Some(e.to_string())),
102 }
103 });
104 });
105 }
106
107 // Destroy the CodeMirror view when the editor closes. Order matters:
108 // `destroy` must release CodeMirror's JS reference before we drop the
109 // Rust-side owner of the change-listener closure.
110 on_cleanup(move || {
111 if let Some(v) = cm_view.try_update_value(Option::take).flatten() {
112 let _ = cm::destroy(&v);
113 }
114 cm_change.try_update_value(Option::take);
115 });
116
117 // Save and overwrite differ only in the conflict check (`force`).
118 let make_save = |force: bool| {
119 let t = target.clone();
120 Callback::new(move |_| {
121 let t = t.clone();
122 spawn_local(async move {
123 let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value));
124 let Some(text) = text else {
125 set_status.set(Some("editor not ready".to_string()));
126 return;
127 };
128 let expected = loaded_mtime.get_value();
129 set_saving.set(true);
130 set_status.set(None);
131 match api::save_content(t.root_id, &t.path, &text, expected, force).await {
132 Ok(new_mtime) => {
133 loaded_mtime.set_value(Some(new_mtime));
134 set_dirty.set(false);
135 set_conflict.set(false);
136 show(toast, i18n::t(i18n::k::SAVED));
137 refresh.run(());
138 }
139 Err(ApiError::Conflict) => set_conflict.set(true),
140 Err(e) => set_status.set(Some(e.to_string())),
141 }
142 set_saving.set(false);
143 });
144 })
145 };
146 let save_cb: Callback<()> = make_save(false);
147 let overwrite_cb: Callback<()> = make_save(true);
148
149 // Expose the save callback to the top bar (read-only mode has none).
150 Effect::new(move |_| {
151 register_save.set(if ro { None } else { Some(save_cb) });
152 });
153 on_cleanup(move || register_save.set(None));
154
155 // Load the latest on-disk version (discards local edits).
156 let load_latest_cb: Callback<()> = {
157 let t = target.clone();
158 Callback::new(move |_| {
159 let t = t.clone();
160 spawn_local(async move {
161 set_status.set(None);
162 match api::fetch_content_meta(t.root_id, &t.path).await {
163 Ok((text, mtime)) => {
164 cm_view.with_value(|v| {
165 if let Some(v) = v.as_ref() {
166 suppress.set_value(true);
167 let _ = cm::set_value(v, &text);
168 }
169 });
170 loaded_mtime.set_value(mtime);
171 set_dirty.set(false);
172 set_conflict.set(false);
173 }
174 Err(e) => set_status.set(Some(e.to_string())),
175 }
176 });
177 })
178 };
179
180 // Close, prompting first if there are unsaved changes.
181 let request_close = Callback::new(move |_| {
182 if dirty.get() {
183 set_confirm_discard.set(true);
184 } else {
185 close.run(());
186 }
187 });
188
189 // Expose the guarded close request to the top bar's X button.
190 Effect::new(move |_| {
191 register_close.set(Some(request_close));
192 });
193 on_cleanup(move || register_close.set(None));
194
195 // Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save
196 // in read-only mode.
197 if !ro {
198 crate::util::owned_window_listener(
199 leptos::ev::keydown,
200 move |ev: web_sys::KeyboardEvent| {
201 if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
202 ev.prevent_default();
203 save_cb.run(());
204 }
205 },
206 );
207 }
208
209 // Escape: closes the unsaved-changes prompt if open, otherwise the error
210 // card if shown, otherwise closes the editor (with the dirty guard). The
211 // conflict dialog is left alone — it requires an explicit choice.
212 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
213 if ev.key() != "Escape" {
214 return;
215 }
216 if confirm_discard.get() {
217 set_confirm_discard.set(false);
218 } else if status.get().is_some() {
219 set_status.set(None);
220 } else if !conflict.get() {
221 request_close.run(());
222 }
223 });
224
225 view! {
226 <div class="file-editor">
227 <div node_ref=node class="cm-container cm-edit"></div>
228 {move || {
229 status.get().map(|e| view! {
230 <div class="cm-error">
231 <div class="cm-error-card">
232 <button
233 class="cm-error-close"
234 title={i18n::tr(i18n::k::DISMISS)}
235 aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
236 on:click=move |_| set_status.set(None)
237 >
238 {icon_svg(IconName::Close, "ic-btn")}
239 </button>
240 <div class="cm-error-text">{e}</div>
241 </div>
242 </div>
243 })
244 }}
245
246 // Conflict: the file changed on disk since it was opened.
247 // Closing it means "keep my version, decide later", so Escape and
248 // a backdrop click leave the editor open and dirty.
249 <Show when=move || conflict.get()>
250 <Modal class="card" on_close=Callback::new(move |_| set_conflict.set(false))>
251 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
252 <p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
253 <div class="modal-actions">
254 <button class="btn" on:click=move |_| load_latest_cb.run(())>
255 {i18n::tr(i18n::k::LOAD_LATEST)}
256 </button>
257 <button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
258 {i18n::tr(i18n::k::OVERWRITE)}
259 </button>
260 </div>
261 </Modal>
262 </Show>
263
264 // Unsaved-changes confirmation. Dismissing it is "keep editing":
265 // the destructive choice needs the button.
266 <Show when=move || confirm_discard.get()>
267 <ConfirmDialog
268 title=i18n::t(i18n::k::UNSAVED_CHANGES).to_string()
269 message=i18n::t(i18n::k::DISCARD_QUESTION).to_string()
270 submit=i18n::t(i18n::k::DISCARD).to_string()
271 danger=true
272 cancel=i18n::t(i18n::k::KEEP_EDITING).to_string()
273 on_submit=close
274 on_close=Callback::new(move |_| set_confirm_discard.set(false))
275 />
276 </Show>
277 </div>
278 }
279}
280