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).
31async fn do_save(
32 target: EditTarget,
33 force: bool,
34 cm_view: &Arc<Mutex<Option<JsValue>>>,
35 loaded_mtime: &Arc<Mutex<Option<i64>>>,
36 set_dirty: WriteSignal<bool>,
37 set_saving: WriteSignal<bool>,
38 set_status: WriteSignal<Option<String>>,
39 set_conflict: WriteSignal<bool>,
40 toast: ToastMsg,
41 refresh: Callback<()>,
42) {
43 let text = match cm_view.lock() {
44 Ok(g) => g.as_ref().and_then(|v| cm::get_value(v)),
45 Err(_) => None,
46 };
47 let Some(text) = text else {
48 set_status.set(Some("editor not ready".to_string()));
49 return;
50 };
51 let expected = loaded_mtime.lock().ok().and_then(|g| *g);
52 set_saving.set(true);
53 set_status.set(None);
54 match api::save_content(target.root_id, &target.path, &text, expected, force).await {
55 Ok(new_mtime) => {
56 if let Ok(mut g) = loaded_mtime.lock() {
57 *g = Some(new_mtime);
58 }
59 set_dirty.set(false);
60 set_conflict.set(false);
61 show(toast, "Saved");
62 refresh.run(());
63 }
64 Err(ApiError::Conflict) => {
65 set_conflict.set(true);
66 }
67 Err(e) => {
68 set_status.set(Some(e.to_string()));
69 }
70 }
71 set_saving.set(false);
72}
73
74/// The editor overlay.
75#[component]
76pub fn EditorModal(
77 target: EditTarget,
78 close: Callback<()>,
79 toast: ToastMsg,
80 refresh: Callback<()>,
81) -> impl IntoView {
82 let node = NodeRef::<leptos::html::Div>::new();
83 let (status, set_status) = signal(Option::<String>::None);
84 let (dirty, set_dirty) = signal(false);
85 let (saving, set_saving) = signal(false);
86 let (conflict, set_conflict) = signal(false);
87 let (confirm_discard, set_confirm_discard) = signal(false);
88 // The mtime the loaded content was anchored to (for the conflict check).
89 let loaded_mtime: Arc<Mutex<Option<i64>>> = Arc::new(Mutex::new(None));
90 // The live CodeMirror view (destroyed on close).
91 let cm_view: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
92 // Set before a programmatic `set_value` so the resulting doc change is not
93 // counted as a user edit (keeps the dirty state honest).
94 let suppress = Arc::new(AtomicBool::new(false));
95
96 let name = target.name.clone();
97
98 // Load the file and create an editable CodeMirror.
99 {
100 let t = target.clone();
101 let st = set_status;
102 let h = cm_view.clone();
103 let sup = suppress.clone();
104 let lt = loaded_mtime.clone();
105 let sd = set_dirty;
106 node.on_load(move |el: web_sys::HtmlDivElement| {
107 spawn_local(async move {
108 match api::fetch_content_meta(t.root_id, &t.path).await {
109 Ok((text, mtime)) => {
110 let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
111 if sup.swap(false, Ordering::SeqCst) {
112 return;
113 }
114 sd.set(true);
115 });
116 // Borrow the JS function while `closure` is alive; the
117 // synchronous create() only needs it for that call.
118 let js_ref: &JsValue = closure.as_ref();
119 match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
120 Ok(c) => {
121 cm::focus(&c.view);
122 let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
123 let _ = lt.lock().map(|mut g| *g = mtime);
124 }
125 Err(e) => st.set(Some(e)),
126 }
127 // Keep the Rust callback alive for the editor's lifetime
128 // (the CM view holds the JS reference; a small, bounded
129 // leak per open, same pattern as the upload picker).
130 closure.forget();
131 }
132 Err(e) => st.set(Some(e.to_string())),
133 }
134 });
135 });
136 }
137
138 // Destroy the CodeMirror view when the editor closes.
139 {
140 let h = cm_view.clone();
141 on_cleanup(move || {
142 if let Ok(mut g) = h.lock() {
143 if let Some(v) = g.take() {
144 cm::destroy(&v);
145 }
146 }
147 });
148 }
149
150 // Save (with the conflict check).
151 let save_cb: Callback<()> = {
152 let t = target.clone();
153 let cmv = cm_view.clone();
154 let lt = loaded_mtime.clone();
155 let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
156 let (toast, refresh) = (toast, refresh);
157 Callback::new(move |_| {
158 let t2 = t.clone();
159 let cmv2 = cmv.clone();
160 let lt2 = lt.clone();
161 let refresh2 = refresh.clone();
162 spawn_local(async move {
163 do_save(t2, false, &cmv2, &lt2, sd, ss, st, sc, toast, refresh2).await;
164 });
165 })
166 };
167
168 // Overwrite (skip the conflict check).
169 let overwrite_cb: Callback<()> = {
170 let t = target.clone();
171 let cmv = cm_view.clone();
172 let lt = loaded_mtime.clone();
173 let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
174 let (toast, refresh) = (toast, refresh);
175 Callback::new(move |_| {
176 let t2 = t.clone();
177 let cmv2 = cmv.clone();
178 let lt2 = lt.clone();
179 let refresh2 = refresh.clone();
180 spawn_local(async move {
181 do_save(t2, true, &cmv2, &lt2, sd, ss, st, sc, toast, refresh2).await;
182 });
183 })
184 };
185
186 // Load the latest on-disk version (discards local edits).
187 let load_latest_cb: Callback<()> = {
188 let t = target.clone();
189 let cmv = cm_view.clone();
190 let lt = loaded_mtime.clone();
191 let sup = suppress.clone();
192 let (sd, st, sc) = (set_dirty, set_status, set_conflict);
193 Callback::new(move |_| {
194 let t2 = t.clone();
195 let cmv2 = cmv.clone();
196 let lt2 = lt.clone();
197 let sup2 = sup.clone();
198 spawn_local(async move {
199 st.set(None);
200 match api::fetch_content_meta(t2.root_id, &t2.path).await {
201 Ok((text, mtime)) => {
202 if let Ok(g) = cmv2.lock() {
203 if let Some(v) = g.as_ref() {
204 sup2.store(true, Ordering::SeqCst);
205 cm::set_value(v, &text);
206 }
207 }
208 if let Ok(mut g) = lt2.lock() {
209 *g = mtime;
210 }
211 sd.set(false);
212 sc.set(false);
213 }
214 Err(e) => st.set(Some(e.to_string())),
215 }
216 });
217 })
218 };
219
220 // Close, prompting first if there are unsaved changes.
221 let request_close: Callback<()> = {
222 let close = close.clone();
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.clone();
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.clone();
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 => view! {}.into_any(),
299 }
300 }}
301 </div>
302 </div>
303
304 // Conflict: the file changed on disk since it was opened.
305 {move || {
306 if conflict.get() {
307 view! {
308 <div class="modal-overlay editor-sub-overlay" on:click=move |e: MouseEvent| e.stop_propagation()>
309 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
310 <h2 class="modal-title">"File changed on disk"</h2>
311 <p class="modal-message">
312 "This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version."
313 </p>
314 <div class="modal-actions">
315 <button class="btn" on:click=move |_| load_latest_cb.run(())>
316 "Load latest"
317 </button>
318 <button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
319 "Overwrite"
320 </button>
321 </div>
322 </div>
323 </div>
324 }
325 .into_view()
326 .into_any()
327 } else {
328 view! {}.into_any()
329 }
330 }}
331
332 // Unsaved-changes confirmation.
333 {move || {
334 if confirm_discard.get() {
335 view! {
336 <div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
337 <div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
338 <h2 class="modal-title">"Unsaved changes"</h2>
339 <p class="modal-message">"Discard your changes and close the editor?"</p>
340 <div class="modal-actions">
341 <button class="btn" on:click=move |_| set_confirm_discard.set(false)>
342 "Keep editing"
343 </button>
344 <button class="btn btn-danger" on:click=move |_| {
345 set_confirm_discard.set(false);
346 close.run(());
347 }>
348 "Discard"
349 </button>
350 </div>
351 </div>
352 </div>
353 }
354 .into_view()
355 .into_any()
356 } else {
357 view! {}.into_any()
358 }
359 }}
360 </div>
361 }
362}
363