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