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::JsValue;
12use wasm_bindgen::closure::Closure;
13use wasm_bindgen_futures::spawn_local;
14
15use crate::api::{self, ApiError};
16use crate::cm;
17use crate::components::icon::Icon;
18use crate::components::toast::{ToastMsg, show};
19use crate::i18n;
20use crate::icons::IconName;
21
22/// A text file the user wants to edit.
23#[derive(Clone)]
24pub struct EditTarget {
25 pub root_id: i64,
26 /// Path relative to the root (e.g. "docs/a.txt").
27 pub path: String,
28 /// Display name (used for the title + language detection).
29 pub name: String,
30 /// True in read-only folders: the file is shown, not editable.
31 pub readonly: bool,
32}
33
34/// Read the current document and PUT it, updating the editor's state.
35///
36/// `force` skips the server's conflict check (used by the "Overwrite" button).
37#[allow(clippy::too_many_arguments)] // long signal/JS-handle list
38async fn do_save(
39 target: EditTarget,
40 force: bool,
41 cm_view: StoredValue<Option<JsValue>, LocalStorage>,
42 loaded_mtime: StoredValue<Option<i64>>,
43 set_dirty: WriteSignal<bool>,
44 set_saving: WriteSignal<bool>,
45 set_status: WriteSignal<Option<String>>,
46 set_conflict: WriteSignal<bool>,
47 toast: ToastMsg,
48 refresh: Callback<()>,
49) {
50 let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value));
51 let Some(text) = text else {
52 set_status.set(Some("editor not ready".to_string()));
53 return;
54 };
55 let expected = loaded_mtime.get_value();
56 set_saving.set(true);
57 set_status.set(None);
58 match api::save_content(target.root_id, &target.path, &text, expected, force).await {
59 Ok(new_mtime) => {
60 loaded_mtime.set_value(Some(new_mtime));
61 set_dirty.set(false);
62 set_conflict.set(false);
63 show(toast, i18n::t(i18n::k::SAVED));
64 refresh.run(());
65 }
66 Err(ApiError::Conflict) => {
67 set_conflict.set(true);
68 }
69 Err(e) => {
70 set_status.set(Some(e.to_string()));
71 }
72 }
73 set_saving.set(false);
74}
75
76/// The full-page editor (no window chrome: the top bar carries the title,
77/// save and close).
78#[component]
79pub fn Editor(
80 target: EditTarget,
81 /// The editor stores its save callback here (top-bar Save button);
82 /// cleared on unmount. Absent in read-only mode.
83 register_save: WriteSignal<Option<Callback<()>>>,
84 /// The top bar's X button calls the registered close request (which
85 /// asks about unsaved changes first); cleared on unmount.
86 register_close: WriteSignal<Option<Callback<()>>>,
87 dirty: ReadSignal<bool>,
88 set_dirty: WriteSignal<bool>,
89 set_saving: WriteSignal<bool>,
90 close: Callback<()>,
91 toast: ToastMsg,
92 /// Re-fetch the file list after a successful save.
93 refresh: Callback<()>,
94) -> impl IntoView {
95 let node = NodeRef::<leptos::html::Div>::new();
96 let (status, set_status) = signal(Option::<String>::None);
97 let (conflict, set_conflict) = signal(false);
98 let (confirm_discard, set_confirm_discard) = signal(false);
99 // The mtime the loaded content was anchored to (for the conflict check).
100 let loaded_mtime = StoredValue::new(Option::<i64>::None);
101 // The live CodeMirror view (destroyed on close).
102 let cm_view = StoredValue::new_local(Option::<JsValue>::None);
103 // The change-listener closure backing the editable CodeMirror. CodeMirror
104 // holds a JS reference to it for the view's lifetime, so we keep the Rust
105 // side here (rather than `forget()`-ing it) and free it in the cleanup
106 // below — after `cm::destroy` releases CodeMirror's reference.
107 let cm_change = StoredValue::new_local(Option::<Closure<dyn FnMut(JsValue)>>::None);
108 // Set before a programmatic `set_value` so the resulting doc change is not
109 // counted as a user edit (keeps the dirty state honest).
110 let suppress = StoredValue::new(false);
111
112 let ro = target.readonly;
113
114 // Load the file and create an editable CodeMirror.
115 {
116 let t = target.clone();
117 let st = set_status;
118 let sd = set_dirty;
119 node.on_load(move |el: web_sys::HtmlDivElement| {
120 spawn_local(async move {
121 match api::fetch_content_meta(t.root_id, &t.path).await {
122 Ok((text, mtime)) => {
123 if ro {
124 match cm::create(&el, &text, &t.name, false, None) {
125 Ok(c) => cm_view.set_value(Some(c.view.clone())),
126 Err(e) => st.set(Some(e)),
127 }
128 return;
129 }
130 let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
131 // Consume the suppression flag: only the change
132 // right after a programmatic set_value is ignored.
133 if suppress.get_value() {
134 suppress.set_value(false);
135 return;
136 }
137 sd.set(true);
138 });
139 // Borrow the JS function while `closure` is alive; the
140 // synchronous create() only needs it for that call.
141 let js_ref: &JsValue = closure.as_ref();
142 match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
143 Ok(c) => {
144 cm::focus(&c.view);
145 cm_view.set_value(Some(c.view.clone()));
146 cm_change.set_value(Some(closure));
147 loaded_mtime.set_value(mtime);
148 }
149 Err(e) => st.set(Some(e)),
150 }
151 // On failure the closure is simply dropped here; on
152 // success it is owned by `cm_change` until cleanup.
153 }
154 Err(e) => st.set(Some(e.to_string())),
155 }
156 });
157 });
158 }
159
160 // Destroy the CodeMirror view when the editor closes. Order matters:
161 // `destroy` must release CodeMirror's JS reference before we drop the
162 // Rust-side owner of the change-listener closure.
163 on_cleanup(move || {
164 if let Some(v) = cm_view.try_update_value(Option::take).flatten() {
165 cm::destroy(&v);
166 }
167 cm_change.try_update_value(Option::take);
168 });
169
170 // Save and overwrite differ only in the conflict check (`force`).
171 let make_save = |force: bool| {
172 let t = target.clone();
173 let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
174 Callback::new(move |_| {
175 let t2 = t.clone();
176 spawn_local(async move {
177 do_save(
178 t2,
179 force,
180 cm_view,
181 loaded_mtime,
182 sd,
183 ss,
184 st,
185 sc,
186 toast,
187 refresh,
188 )
189 .await;
190 });
191 })
192 };
193 let save_cb: Callback<()> = make_save(false);
194 let overwrite_cb: Callback<()> = make_save(true);
195
196 // Expose the save callback to the top bar (read-only mode has none).
197 {
198 let rs = register_save;
199 let cb = save_cb;
200 Effect::new(move |_| {
201 rs.set(if ro { None } else { Some(cb) });
202 });
203 on_cleanup(move || rs.set(None));
204 }
205
206 // Load the latest on-disk version (discards local edits).
207 let load_latest_cb: Callback<()> = {
208 let t = target.clone();
209 let (sd, st, sc) = (set_dirty, set_status, set_conflict);
210 Callback::new(move |_| {
211 let t2 = t.clone();
212 spawn_local(async move {
213 st.set(None);
214 match api::fetch_content_meta(t2.root_id, &t2.path).await {
215 Ok((text, mtime)) => {
216 cm_view.with_value(|v| {
217 if let Some(v) = v.as_ref() {
218 suppress.set_value(true);
219 cm::set_value(v, &text);
220 }
221 });
222 loaded_mtime.set_value(mtime);
223 sd.set(false);
224 sc.set(false);
225 }
226 Err(e) => st.set(Some(e.to_string())),
227 }
228 });
229 })
230 };
231
232 // Close, prompting first if there are unsaved changes.
233 let request_close: Callback<()> = {
234 let d = dirty;
235 let scd = set_confirm_discard;
236 Callback::new(move |_| {
237 if d.get() {
238 scd.set(true);
239 } else {
240 close.run(());
241 }
242 })
243 };
244
245 // Expose the guarded close request to the top bar's X button.
246 {
247 let rc = request_close;
248 let sig = register_close;
249 Effect::new(move |_| {
250 sig.set(Some(rc));
251 });
252 on_cleanup(move || sig.set(None));
253 }
254
255 // Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save
256 // in read-only mode.
257 if !ro {
258 let cb = save_cb;
259 crate::util::owned_window_listener(
260 leptos::ev::keydown,
261 move |ev: web_sys::KeyboardEvent| {
262 if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
263 ev.prevent_default();
264 cb.run(());
265 }
266 },
267 );
268 }
269
270 // Escape: closes the unsaved-changes prompt if open, otherwise the error
271 // card if shown, otherwise closes the editor (with the dirty guard). The
272 // conflict dialog is left alone — it requires an explicit choice.
273 {
274 let cd = confirm_discard;
275 let scd = set_confirm_discard;
276 let cf = conflict;
277 let st = status;
278 let stx = set_status;
279 let rc = request_close;
280 crate::util::owned_window_listener(
281 leptos::ev::keydown,
282 move |ev: web_sys::KeyboardEvent| {
283 if ev.key() != "Escape" {
284 return;
285 }
286 if cd.get() {
287 scd.set(false);
288 } else if st.get().is_some() {
289 stx.set(None);
290 } else if !cf.get() {
291 rc.run(());
292 }
293 },
294 );
295 }
296
297 view! {
298 <div class="file-editor">
299 <div node_ref=node class="cm-container cm-edit"></div>
300 {move || {
301 let err = status.get();
302 match err {
303 Some(e) => view! {
304 <div class="cm-error">
305 <div class="cm-error-card">
306 <button
307 class="cm-error-close"
308 title={i18n::tr(i18n::k::DISMISS)}
309 aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
310 on:click=move |_| set_status.set(None)
311 >
312 <Icon name=IconName::Close class="ic-btn".to_string()/>
313 </button>
314 <div class="cm-error-text">{e}</div>
315 </div>
316 </div>
317 }
318 .into_view()
319 .into_any(),
320 None => {
321 view! {}.into_any()
322 },
323 }
324 }}
325
326 // Conflict: the file changed on disk since it was opened.
327 {move || {
328 if conflict.get() {
329 view! {
330 <div class="modal-overlay editor-sub-overlay" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
331 <div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
332 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
333 <p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
334 <div class="modal-actions">
335 <button class="btn" on:click=move |_| load_latest_cb.run(())>
336 {i18n::tr(i18n::k::LOAD_LATEST)}
337 </button>
338 <button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
339 {i18n::tr(i18n::k::OVERWRITE)}
340 </button>
341 </div>
342 </div>
343 </div>
344 }
345 .into_view()
346 .into_any()
347 } else {
348 view! {}.into_any()
349 }
350 }}
351
352 // Unsaved-changes confirmation.
353 {move || {
354 if confirm_discard.get() {
355 view! {
356 <div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
357 <div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
358 <h2 class="modal-title">{i18n::tr(i18n::k::UNSAVED_CHANGES)}</h2>
359 <p class="modal-message">{i18n::tr(i18n::k::DISCARD_QUESTION)}</p>
360 <div class="modal-actions">
361 <button class="btn" on:click=move |_| set_confirm_discard.set(false)>
362 {i18n::tr(i18n::k::KEEP_EDITING)}
363 </button>
364 <button class="btn btn-danger" on:click=move |_| {
365 set_confirm_discard.set(false);
366 close.run(());
367 }>
368 {i18n::tr(i18n::k::DISCARD)}
369 </button>
370 </div>
371 </div>
372 </div>
373 }
374 .into_view()
375 .into_any()
376 } else {
377 view! {}.into_any()
378 }
379 }}
380 </div>
381 }
382}
383