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::closure::Closure;
12use wasm_bindgen::{JsCast, JsValue};
13use wasm_bindgen_futures::spawn_local;
14
15use crate::api::{self, ApiError};
16use crate::cm;
17use crate::components::icon::Icon;
18use crate::components::modal::Modal;
19use crate::components::toast::{ToastMsg, show};
20use crate::i18n;
21use crate::icons::IconName;
22use crate::views::dialogs::ConfirmDialog;
23use crate::views::file_view::FileView;
24
25/// Read the current document and PUT it, updating the editor's state.
26///
27/// `force` skips the server's conflict check (used by the "Overwrite" button).
28#[allow(clippy::too_many_arguments)] // long signal/JS-handle list
29async fn do_save(
30 target: FileView,
31 force: bool,
32 cm_view: StoredValue<Option<JsValue>, LocalStorage>,
33 loaded_mtime: StoredValue<Option<i64>>,
34 set_dirty: WriteSignal<bool>,
35 set_saving: WriteSignal<bool>,
36 set_status: WriteSignal<Option<String>>,
37 set_conflict: WriteSignal<bool>,
38 toast: ToastMsg,
39 refresh: Callback<()>,
40) {
41 let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value));
42 let Some(text) = text else {
43 set_status.set(Some("editor not ready".to_string()));
44 return;
45 };
46 let expected = loaded_mtime.get_value();
47 set_saving.set(true);
48 set_status.set(None);
49 match api::save_content(target.root_id, &target.path, &text, expected, force).await {
50 Ok(new_mtime) => {
51 loaded_mtime.set_value(Some(new_mtime));
52 set_dirty.set(false);
53 set_conflict.set(false);
54 show(toast, i18n::t(i18n::k::SAVED));
55 refresh.run(());
56 }
57 Err(ApiError::Conflict) => {
58 set_conflict.set(true);
59 }
60 Err(e) => {
61 set_status.set(Some(e.to_string()));
62 }
63 }
64 set_saving.set(false);
65}
66
67/// The full-page editor (no window chrome: the top bar carries the title,
68/// save and close).
69#[component]
70pub fn Editor(
71 target: FileView,
72 /// The editor stores its save callback here (top-bar Save button);
73 /// cleared on unmount. Absent in read-only mode.
74 register_save: WriteSignal<Option<Callback<()>>>,
75 /// The top bar's X button calls the registered close request (which
76 /// asks about unsaved changes first); cleared on unmount.
77 register_close: WriteSignal<Option<Callback<()>>>,
78 dirty: ReadSignal<bool>,
79 set_dirty: WriteSignal<bool>,
80 set_saving: WriteSignal<bool>,
81 close: Callback<()>,
82 toast: ToastMsg,
83 /// Re-fetch the file list after a successful save.
84 refresh: Callback<()>,
85) -> impl IntoView {
86 let node = NodeRef::<leptos::html::Div>::new();
87 let (status, set_status) = signal(Option::<String>::None);
88 let (conflict, set_conflict) = signal(false);
89 let (confirm_discard, set_confirm_discard) = signal(false);
90 // The mtime the loaded content was anchored to (for the conflict check).
91 let loaded_mtime = StoredValue::new(Option::<i64>::None);
92 // The live CodeMirror view (destroyed on close).
93 let cm_view = StoredValue::new_local(Option::<JsValue>::None);
94 // The change-listener closure backing the editable CodeMirror. CodeMirror
95 // holds a JS reference to it for the view's lifetime, so we keep the Rust
96 // side here (rather than `forget()`-ing it) and free it in the cleanup
97 // below — after `cm::destroy` releases CodeMirror's reference.
98 let cm_change = StoredValue::new_local(Option::<Closure<dyn FnMut(JsValue)>>::None);
99 // Set before a programmatic `set_value` so the resulting doc change is not
100 // counted as a user edit (keeps the dirty state honest).
101 let suppress = StoredValue::new(false);
102
103 let ro = target.readonly;
104
105 // Load the file and create an editable CodeMirror.
106 {
107 let t = target.clone();
108 node.on_load(move |el: web_sys::HtmlDivElement| {
109 spawn_local(async move {
110 match api::fetch_content_meta(t.root_id, &t.path).await {
111 Ok((text, mtime)) => {
112 if ro {
113 match cm::create(&el, &text, &t.name, false, None) {
114 Ok(v) => cm_view.set_value(Some(v)),
115 Err(e) => set_status.set(Some(e)),
116 }
117 return;
118 }
119 let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
120 // Consume the suppression flag: only the change
121 // right after a programmatic set_value is ignored.
122 if suppress.get_value() {
123 suppress.set_value(false);
124 return;
125 }
126 set_dirty.set(true);
127 });
128 // Borrow the JS function while `closure` is alive; the
129 // synchronous create() only needs it for that call.
130 let js_ref = closure.as_ref().unchecked_ref();
131 match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
132 Ok(v) => {
133 let _ = cm::focus(&v);
134 cm_view.set_value(Some(v));
135 cm_change.set_value(Some(closure));
136 loaded_mtime.set_value(mtime);
137 }
138 Err(e) => set_status.set(Some(e)),
139 }
140 // On failure the closure is simply dropped here; on
141 // success it is owned by `cm_change` until cleanup.
142 }
143 Err(e) => set_status.set(Some(e.to_string())),
144 }
145 });
146 });
147 }
148
149 // Destroy the CodeMirror view when the editor closes. Order matters:
150 // `destroy` must release CodeMirror's JS reference before we drop the
151 // Rust-side owner of the change-listener closure.
152 on_cleanup(move || {
153 if let Some(v) = cm_view.try_update_value(Option::take).flatten() {
154 let _ = cm::destroy(&v);
155 }
156 cm_change.try_update_value(Option::take);
157 });
158
159 // Save and overwrite differ only in the conflict check (`force`).
160 let make_save = |force: bool| {
161 let t = target.clone();
162 Callback::new(move |_| {
163 spawn_local(do_save(
164 t.clone(),
165 force,
166 cm_view,
167 loaded_mtime,
168 set_dirty,
169 set_saving,
170 set_status,
171 set_conflict,
172 toast,
173 refresh,
174 ));
175 })
176 };
177 let save_cb: Callback<()> = make_save(false);
178 let overwrite_cb: Callback<()> = make_save(true);
179
180 // Expose the save callback to the top bar (read-only mode has none).
181 Effect::new(move |_| {
182 register_save.set(if ro { None } else { Some(save_cb) });
183 });
184 on_cleanup(move || register_save.set(None));
185
186 // Load the latest on-disk version (discards local edits).
187 let load_latest_cb: Callback<()> = {
188 let t = target.clone();
189 Callback::new(move |_| {
190 let t = t.clone();
191 spawn_local(async move {
192 set_status.set(None);
193 match api::fetch_content_meta(t.root_id, &t.path).await {
194 Ok((text, mtime)) => {
195 cm_view.with_value(|v| {
196 if let Some(v) = v.as_ref() {
197 suppress.set_value(true);
198 let _ = cm::set_value(v, &text);
199 }
200 });
201 loaded_mtime.set_value(mtime);
202 set_dirty.set(false);
203 set_conflict.set(false);
204 }
205 Err(e) => set_status.set(Some(e.to_string())),
206 }
207 });
208 })
209 };
210
211 // Close, prompting first if there are unsaved changes.
212 let request_close = Callback::new(move |_| {
213 if dirty.get() {
214 set_confirm_discard.set(true);
215 } else {
216 close.run(());
217 }
218 });
219
220 // Expose the guarded close request to the top bar's X button.
221 Effect::new(move |_| {
222 register_close.set(Some(request_close));
223 });
224 on_cleanup(move || register_close.set(None));
225
226 // Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save
227 // in read-only mode.
228 if !ro {
229 crate::util::owned_window_listener(
230 leptos::ev::keydown,
231 move |ev: web_sys::KeyboardEvent| {
232 if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
233 ev.prevent_default();
234 save_cb.run(());
235 }
236 },
237 );
238 }
239
240 // Escape: closes the unsaved-changes prompt if open, otherwise the error
241 // card if shown, otherwise closes the editor (with the dirty guard). The
242 // conflict dialog is left alone — it requires an explicit choice.
243 crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
244 if ev.key() != "Escape" {
245 return;
246 }
247 if confirm_discard.get() {
248 set_confirm_discard.set(false);
249 } else if status.get().is_some() {
250 set_status.set(None);
251 } else if !conflict.get() {
252 request_close.run(());
253 }
254 });
255
256 view! {
257 <div class="file-editor">
258 <div node_ref=node class="cm-container cm-edit"></div>
259 {move || {
260 status.get().map(|e| view! {
261 <div class="cm-error">
262 <div class="cm-error-card">
263 <button
264 class="cm-error-close"
265 title={i18n::tr(i18n::k::DISMISS)}
266 aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
267 on:click=move |_| set_status.set(None)
268 >
269 <Icon name=IconName::Close class="ic-btn".to_string()/>
270 </button>
271 <div class="cm-error-text">{e}</div>
272 </div>
273 </div>
274 })
275 }}
276
277 // Conflict: the file changed on disk since it was opened.
278 // Closing it means "keep my version, decide later", so Escape and
279 // a backdrop click leave the editor open and dirty.
280 <Show when=move || conflict.get()>
281 <Modal class="card" on_close=Callback::new(move |_| set_conflict.set(false))>
282 <h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
283 <p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
284 <div class="modal-actions">
285 <button class="btn" on:click=move |_| load_latest_cb.run(())>
286 {i18n::tr(i18n::k::LOAD_LATEST)}
287 </button>
288 <button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
289 {i18n::tr(i18n::k::OVERWRITE)}
290 </button>
291 </div>
292 </Modal>
293 </Show>
294
295 // Unsaved-changes confirmation. Dismissing it is "keep editing":
296 // the destructive choice needs the button.
297 <Show when=move || confirm_discard.get()>
298 <ConfirmDialog
299 title=i18n::t(i18n::k::UNSAVED_CHANGES).to_string()
300 message=i18n::t(i18n::k::DISCARD_QUESTION).to_string()
301 submit=i18n::t(i18n::k::DISCARD).to_string()
302 danger=true
303 cancel=i18n::t(i18n::k::KEEP_EDITING).to_string()
304 on_submit=close
305 on_close=Callback::new(move |_| set_confirm_discard.set(false))
306 />
307 </Show>
308 </div>
309 }
310}
311