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