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