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