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::closure::Closure;
10use wasm_bindgen::JsValue;
11use wasm_bindgen_futures::spawn_local;
12use web_sys::MouseEvent;
13
14use crate::api::{self, ApiError};
15use crate::cm;
16use crate::components::toast::{show, ToastMsg};
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 let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
238 if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
239 ev.prevent_default();
240 cb.run(());
241 }
242 });
243 }
244
245 // Escape: closes the unsaved-changes prompt if open, otherwise closes the
246 // editor (with the dirty guard). The conflict dialog is left alone — it
247 // requires an explicit choice.
248 {
249 let cd = confirm_discard;
250 let scd = set_confirm_discard;
251 let cf = conflict;
252 let rc = request_close;
253 let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
254 if ev.key() != "Escape" {
255 return;
256 }
257 if cd.get() {
258 scd.set(false);
259 } else if !cf.get() {
260 rc.run(());
261 }
262 });
263 }
264
265 view! {
266 <div class="modal-overlay editor-overlay" on:click=move |_| request_close.run(())>
267 <div class="modal editor-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
268 <div class="editor-head">
269 <span class="editor-title">
270 {name}
271 {move || if dirty.get() { " •" } else { " " }}
272 </span>
273 <div class="editor-actions">
274 <button
275 class="btn btn-primary"
276 disabled=move || !dirty.get() || saving.get()
277 on:click=move |_| save_cb.run(())
278 >
279 {move || if saving.get() { "Saving…" } else { "Save" }}
280 </button>
281 <button
282 class="preview-close"
283 title="Close"
284 on:click=move |_| request_close.run(())
285 >
286 "×"
287 </button>
288 </div>
289 </div>
290 <div class="editor-body">
291 <div node_ref=node class="cm-container cm-edit"></div>
292 {move || {
293 let err = status.get();
294 match err {
295 Some(e) => view! { <div class="cm-error">{e}</div> }
296 .into_view()
297 .into_any(),
298 None => {
299 view! {}.into_any()
300 },
301 }
302 }}
303 </div>
304 </div>
305
306 // Conflict: the file changed on disk since it was opened.
307 {move || {
308 if conflict.get() {
309 view! {
310 <div class="modal-overlay editor-sub-overlay" on:click=move |e: MouseEvent| e.stop_propagation()>
311 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
312 <h2 class="modal-title">"File changed on disk"</h2>
313 <p class="modal-message">
314 "This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version."
315 </p>
316 <div class="modal-actions">
317 <button class="btn" on:click=move |_| load_latest_cb.run(())>
318 "Load latest"
319 </button>
320 <button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
321 "Overwrite"
322 </button>
323 </div>
324 </div>
325 </div>
326 }
327 .into_view()
328 .into_any()
329 } else {
330 view! {}.into_any()
331 }
332 }}
333
334 // Unsaved-changes confirmation.
335 {move || {
336 if confirm_discard.get() {
337 view! {
338 <div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
339 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
340 <h2 class="modal-title">"Unsaved changes"</h2>
341 <p class="modal-message">"Discard your changes and close the editor?"</p>
342 <div class="modal-actions">
343 <button class="btn" on:click=move |_| set_confirm_discard.set(false)>
344 "Keep editing"
345 </button>
346 <button class="btn btn-danger" on:click=move |_| {
347 set_confirm_discard.set(false);
348 close.run(());
349 }>
350 "Discard"
351 </button>
352 </div>
353 </div>
354 </div>
355 }
356 .into_view()
357 .into_any()
358 } else {
359 view! {}.into_any()
360 }
361 }}
362 </div>
363 }
364}
365