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