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