editor.rs
⎇
Raw
1//! Text editor (milestone 5): an editable CodeMirror with save, a dirty-state
2//! indicator, Ctrl/Cmd+S, and save-time conflict handling (the file changed on
3//! disk since it was opened).
4
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex};
7
8use leptos::prelude::*;
9use wasm_bindgen::JsValue;
10use wasm_bindgen::closure::Closure;
11use wasm_bindgen_futures::spawn_local;
12use web_sys::MouseEvent;
13
14use crate::api::{self, ApiError};
15use crate::cm;
16use crate::components::toast::{ToastMsg, show};
17
18/// A text file the user wants to edit.
19#[derive(Clone)]
20pub struct EditTarget {
21 pub root_id: i64,
22 /// Path relative to the root (e.g. "docs/a.txt").
23 pub path: String,
24 /// Display name (used for the title + language detection).
25 pub name: String,
26}
27
28/// Read the current document and PUT it, updating the editor's state.
29///
30/// `force` skips the server's conflict check (used by the "Overwrite" button).
31#[allow(clippy::too_many_arguments)] // long signal/JS-handle list
32async fn do_save(
33 target: EditTarget,
34 force: bool,
35 cm_view: &Arc<Mutex<Option<JsValue>>>,
36 loaded_mtime: &Arc<Mutex<Option<i64>>>,
37 set_dirty: WriteSignal<bool>,
38 set_saving: WriteSignal<bool>,
39 set_status: WriteSignal<Option<String>>,
40 set_conflict: WriteSignal<bool>,
41 toast: ToastMsg,
42 refresh: Callback<()>,
43) {
44 let text = match cm_view.lock() {
45 Ok(g) => g.as_ref().and_then(cm::get_value),
46 Err(_) => None,
47 };
48 let Some(text) = text else {
49 set_status.set(Some("editor not ready".to_string()));
50 return;
51 };
52 let expected = loaded_mtime.lock().ok().and_then(|g| *g);
53 set_saving.set(true);
54 set_status.set(None);
55 match api::save_content(target.root_id, &target.path, &text, expected, force).await {
56 Ok(new_mtime) => {
57 if let Ok(mut g) = loaded_mtime.lock() {
58 *g = Some(new_mtime);
59 }
60 set_dirty.set(false);
61 set_conflict.set(false);
62 show(toast, "Saved");
63 refresh.run(());
64 }
65 Err(ApiError::Conflict) => {
66 set_conflict.set(true);
67 }
68 Err(e) => {
69 set_status.set(Some(e.to_string()));
70 }
71 }
72 set_saving.set(false);
73}
74
75/// The editor overlay.
76#[component]
77pub fn EditorModal(
78 target: EditTarget,
79 close: Callback<()>,
80 toast: ToastMsg,
81 refresh: Callback<()>,
82) -> impl IntoView {
83 let node = NodeRef::<leptos::html::Div>::new();
84 let (status, set_status) = signal(Option::<String>::None);
85 let (dirty, set_dirty) = signal(false);
86 let (saving, set_saving) = signal(false);
87 let (conflict, set_conflict) = signal(false);
88 let (confirm_discard, set_confirm_discard) = signal(false);
89 // The mtime the loaded content was anchored to (for the conflict check).
90 let loaded_mtime: Arc<Mutex<Option<i64>>> = Arc::new(Mutex::new(None));
91 // The live CodeMirror view (destroyed on close).
92 let cm_view: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
93 // Set before a programmatic `set_value` so the resulting doc change is not
94 // counted as a user edit (keeps the dirty state honest).
95 let suppress = Arc::new(AtomicBool::new(false));
96
97 let name = target.name.clone();
98
99 // Load the file and create an editable CodeMirror.
100 {
101 let t = target.clone();
102 let st = set_status;
103 let h = cm_view.clone();
104 let sup = suppress.clone();
105 let lt = loaded_mtime.clone();
106 let sd = set_dirty;
107 node.on_load(move |el: web_sys::HtmlDivElement| {
108 spawn_local(async move {
109 match api::fetch_content_meta(t.root_id, &t.path).await {
110 Ok((text, mtime)) => {
111 let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
112 if sup.swap(false, Ordering::SeqCst) {
113 return;
114 }
115 sd.set(true);
116 });
117 // Borrow the JS function while `closure` is alive; the
118 // synchronous create() only needs it for that call.
119 let js_ref: &JsValue = closure.as_ref();
120 match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
121 Ok(c) => {
122 cm::focus(&c.view);
123 let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
124 let _ = lt.lock().map(|mut g| *g = mtime);
125 }
126 Err(e) => st.set(Some(e)),
127 }
128 // Keep the Rust callback alive for the editor's lifetime
129 // (the CM view holds the JS reference; a small, bounded
130 // leak per open, same pattern as the upload picker).
131 closure.forget();
132 }
133 Err(e) => st.set(Some(e.to_string())),
134 }
135 });
136 });
137 }
138
139 // Destroy the CodeMirror view when the editor closes.
140 {
141 let h = cm_view.clone();
142 on_cleanup(move || {
143 if let Ok(mut g) = h.lock() {
144 if let Some(v) = g.take() {
145 cm::destroy(&v);
146 }
147 }
148 });
149 }
150
151 // Save (with the conflict check).
152 let save_cb: Callback<()> = {
153 let t = target.clone();
154 let cmv = cm_view.clone();
155 let lt = loaded_mtime.clone();
156 let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
157 let (toast, refresh) = (toast, refresh);
158 Callback::new(move |_| {
159 let t2 = t.clone();
160 let cmv2 = cmv.clone();
161 let lt2 = lt.clone();
162 let refresh2 = refresh;
163 spawn_local(async move {
164 do_save(t2, false, &cmv2, &lt2, sd, ss, st, sc, toast, refresh2).await;
165 });
166 })
167 };
168
169 // Overwrite (skip the conflict check).
170 let overwrite_cb: Callback<()> = {
171 let t = target.clone();
172 let cmv = cm_view.clone();
173 let lt = loaded_mtime.clone();
174 let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
175 let (toast, refresh) = (toast, refresh);
176 Callback::new(move |_| {
177 let t2 = t.clone();
178 let cmv2 = cmv.clone();
179 let lt2 = lt.clone();
180 let refresh2 = refresh;
181 spawn_local(async move {
182 do_save(t2, true, &cmv2, &lt2, sd, ss, st, sc, toast, refresh2).await;
183 });
184 })
185 };
186
187 // Load the latest on-disk version (discards local edits).
188 let load_latest_cb: Callback<()> = {
189 let t = target.clone();
190 let cmv = cm_view.clone();
191 let lt = loaded_mtime.clone();
192 let sup = suppress.clone();
193 let (sd, st, sc) = (set_dirty, set_status, set_conflict);
194 Callback::new(move |_| {
195 let t2 = t.clone();
196 let cmv2 = cmv.clone();
197 let lt2 = lt.clone();
198 let sup2 = sup.clone();
199 spawn_local(async move {
200 st.set(None);
201 match api::fetch_content_meta(t2.root_id, &t2.path).await {
202 Ok((text, mtime)) => {
203 if let Ok(g) = cmv2.lock() {
204 if let Some(v) = g.as_ref() {
205 sup2.store(true, Ordering::SeqCst);
206 cm::set_value(v, &text);
207 }
208 }
209 if let Ok(mut g) = lt2.lock() {
210 *g = mtime;
211 }
212 sd.set(false);
213 sc.set(false);
214 }
215 Err(e) => st.set(Some(e.to_string())),
216 }
217 });
218 })
219 };
220
221 // Close, prompting first if there are unsaved changes.
222 let request_close: Callback<()> = {
223 let d = dirty;
224 let scd = set_confirm_discard;
225 Callback::new(move |_| {
226 if d.get() {
227 scd.set(true);
228 } else {
229 close.run(());
230 }
231 })
232 };
233
234 // Ctrl/Cmd+S saves (scoped to the editor's lifetime).
235 {
236 let cb = save_cb;
237 crate::util::owned_window_listener(
238 leptos::ev::keydown,
239 move |ev: web_sys::KeyboardEvent| {
240 if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
241 ev.prevent_default();
242 cb.run(());
243 }
244 },
245 );
246 }
247
248 // Escape: closes the unsaved-changes prompt if open, otherwise closes the
249 // editor (with the dirty guard). The conflict dialog is left alone — it
250 // requires an explicit choice.
251 {
252 let cd = confirm_discard;
253 let scd = set_confirm_discard;
254 let cf = conflict;
255 let rc = request_close;
256 crate::util::owned_window_listener(
257 leptos::ev::keydown,
258 move |ev: web_sys::KeyboardEvent| {
259 if ev.key() != "Escape" {
260 return;
261 }
262 if cd.get() {
263 scd.set(false);
264 } else if !cf.get() {
265 rc.run(());
266 }
267 },
268 );
269 }
270
271 view! {
272 <div class="modal-overlay editor-overlay" on:click=move |_| request_close.run(())>
273 <div class="modal editor-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
274 <div class="editor-head">
275 <span class="editor-title">
276 {name}
277 {move || if dirty.get() { " •" } else { " " }}
278 </span>
279 <div class="editor-actions">
280 <button
281 class="btn btn-primary"
282 disabled=move || !dirty.get() || saving.get()
283 on:click=move |_| save_cb.run(())
284 >
285 {move || if saving.get() { "Saving…" } else { "Save" }}
286 </button>
287 <button
288 class="preview-close"
289 title="Close"
290 on:click=move |_| request_close.run(())
291 >
292 "×"
293 </button>
294 </div>
295 </div>
296 <div class="editor-body">
297 <div node_ref=node class="cm-container cm-edit"></div>
298 {move || {
299 let err = status.get();
300 match err {
301 Some(e) => view! { <div class="cm-error">{e}</div> }
302 .into_view()
303 .into_any(),
304 None => {
305 view! {}.into_any()
306 },
307 }
308 }}
309 </div>
310 </div>
311
312 // Conflict: the file changed on disk since it was opened.
313 {move || {
314 if conflict.get() {
315 view! {
316 <div class="modal-overlay editor-sub-overlay" on:click=move |e: MouseEvent| e.stop_propagation()>
317 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
318 <h2 class="modal-title">"File changed on disk"</h2>
319 <p class="modal-message">
320 "This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version."
321 </p>
322 <div class="modal-actions">
323 <button class="btn" on:click=move |_| load_latest_cb.run(())>
324 "Load latest"
325 </button>
326 <button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
327 "Overwrite"
328 </button>
329 </div>
330 </div>
331 </div>
332 }
333 .into_view()
334 .into_any()
335 } else {
336 view! {}.into_any()
337 }
338 }}
339
340 // Unsaved-changes confirmation.
341 {move || {
342 if confirm_discard.get() {
343 view! {
344 <div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
345 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
346 <h2 class="modal-title">"Unsaved changes"</h2>
347 <p class="modal-message">"Discard your changes and close the editor?"</p>
348 <div class="modal-actions">
349 <button class="btn" on:click=move |_| set_confirm_discard.set(false)>
350 "Keep editing"
351 </button>
352 <button class="btn btn-danger" on:click=move |_| {
353 set_confirm_discard.set(false);
354 close.run(());
355 }>
356 "Discard"
357 </button>
358 </div>
359 </div>
360 </div>
361 }
362 .into_view()
363 .into_any()
364 } else {
365 view! {}.into_any()
366 }
367 }}
368 </div>
369 }
370}
371