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