M5: text editor (editable CodeMirror) with save, Ctrl+S, and conflict handling

Editor
- Context-menu "Edit" on text/code files (read-write roots) opens an editable
  CodeMirror modal pre-filled with the file's content.
- Dirty-state indicator (a "•" in the title) and an enabled/disabled Save
  button; Ctrl/Cmd+S saves.
- Closing with unsaved changes prompts "Keep editing" / "Discard".
- Save-time conflict: if the file changed on disk since it was opened (checked
  via an mtime anchor), a dialog offers "Load latest" (discard local edits) or
  "Overwrite" (keep local edits).

API
- GET  ...?action=content now also returns the file mtime (X-File-Mtime header).
- PUT  ...?action=content saves the text body; sends X-Expected-Mtime for the
  conflict check (409 on mismatch), capped at 2 MiB. fs::save_file writes and
  returns the new mtime to anchor the next check.

Fix
- Context-menu actions read `loc.root_id`, which is None at the default
  single-root location (#/), silently no-op'ing. All of them (download, edit,
  rename, move, copy, delete, new folder, upload) now resolve the *effective*
  root id, so they work at any location.

Verified in browser: open/edit, dirty tracking, Ctrl+S save to disk, conflict
detection, Load latest, Overwrite, and the unsaved-changes confirmation.

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commit9fdfb08a1eb7b860285f8fc791a98aca801ff0ce
Parent9fbcdbd
10 files changed, 716 insertions(+), 35 deletions(-)
▾Mserver/src/api/files.rs
@@ -12,6 +12,7 @@
use std::io::{self, Read};
use std::path::Component;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
use axum::http::{header, StatusCode};
@@ -220,13 +221,73 @@ async fn content(
let bytes = tokio::fs::read(&full)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let meta = tokio::fs::metadata(&full)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
Ok((
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
[
(
header::CONTENT_TYPE,
"text/plain; charset=utf-8".to_string(),
),
(
axum::http::HeaderName::from_static("x-file-mtime"),
mtime.to_string(),
),
],
bytes,
)
.into_response())
}
/// `PUT ...?action=content` — save a file's text contents (the editor).
///
/// Requires a read-write root. The body is the new contents. If the
/// `X-Expected-Mtime` header is present, the file's current mtime must match
/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
/// Returns the file's new mtime so the client can anchor the next check.
pub async fn file_put(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<serde_json::Value>, ApiError> {
let (root_id, req_rel) = path.0;
if query.action.as_deref() != Some("content") {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"expected action=content",
));
}
let root = require_rw_root(&auth.roots, root_id)?;
if body.len() as u64 > MAX_TEXT_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"file too large to save",
));
}
let expected: Option<i64> = headers
.get("x-expected-mtime")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let content = body.to_vec();
let mtime = tokio::task::spawn_blocking(move || {
fs::save_file(&server_root, &root_rel, &rel, &content, expected)
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true, "mtime": mtime })))
}
/// Stream a single file to the client with the right disposition.
async fn file_response(
full: &std::path::Path,
▾Mserver/src/api/mod.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, post, put};
use axum::Router;
use crate::error::AppState;
@@ -18,6 +18,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/auth/setup", post(auth::setup))
.route("/api/files/{root_id}", get(files::list_root))
.route("/api/files/{root_id}/{*path}", get(files::file_get))
.route("/api/files/{root_id}/{*path}", put(files::file_put))
.route("/api/files/{root_id}", post(files::dispatch_root))
.route("/api/files/{root_id}/{*path}", post(files::dispatch))
.route("/api/files/{root_id}/{*path}", delete(files::delete))
▾Mserver/src/fs.rs
@@ -229,6 +229,47 @@ pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<
Ok(is_dir)
}
/// Overwrite an existing file's contents (the editor's save path).
///
/// The file must already exist and be a regular file. If `expected_mtime`
/// (whole unix seconds) is provided and differs from the file's current mtime,
/// the file changed on disk since it was read → `Conflict` (409). Returns the
/// file's new mtime (unix seconds) after a successful write.
pub fn save_file(
server_root: &Path,
root_rel: &str,
req_rel: &str,
content: &[u8],
expected_mtime: Option<i64>,
) -> Result<i64, FsError> {
let full = resolve_path(server_root, root_rel, req_rel)?; // must exist
let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
if meta.is_dir() {
return Err(FsError::NotADirectory);
}
if let Some(expected) = expected_mtime {
let cur = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(-1);
if cur != expected {
return Err(FsError::Conflict);
}
}
std::fs::write(&full, content).map_err(|e| io_err(e, &full))?;
// Read the new mtime so the client can anchor the next conflict check.
let new_meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
let mtime = new_meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Ok(mtime)
}
fn io_err(e: std::io::Error, p: &Path) -> FsError {
tracing::warn!(error = %e, path = %p.display(), "filesystem error");
match e.kind() {
▾Mweb/app.css
@@ -696,6 +696,55 @@ button:disabled {
text-align: center;
}
/* ---------------------------------------------------------------------------
* Text editor (milestone 5)
* ------------------------------------------------------------------------- */
.editor-modal {
width: min(960px, calc(100vw - 48px));
height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.editor-title {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex: none;
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
position: relative;
background: var(--bg);
}
.editor-body .cm-container {
width: 100%;
height: 100%;
}
/* Conflict / discard dialogs render above the editor overlay. */
.editor-sub-overlay {
z-index: 101;
background: rgb(0 0 0 / 25%);
}
/* ---------------------------------------------------------------------------
* Download format dialog
* ------------------------------------------------------------------------- */
▾Mweb/src/api.rs
@@ -15,6 +15,9 @@ pub enum ApiError {
message: String,
skipped: Option<Vec<String>>,
},
/// The file changed on disk since it was read (save conflict, HTTP 409).
#[error("the file was changed on disk")]
Conflict,
#[error("network error: {0}")]
Net(String),
}
@@ -296,6 +299,123 @@ pub async fn fetch_content(root_id: i64, path: &str) -> Result<String, ApiError>
js.as_string().ok_or_else(|| ApiError::Net("content is not a string".to_string()))
}
/// Fetch a file's raw text content plus its mtime (unix seconds), for the
/// editor. The mtime anchors the save-time conflict check.
pub async fn fetch_content_meta(
root_id: i64,
path: &str,
) -> Result<(String, Option<i64>), ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_method("GET");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "could not read file".to_string()),
skipped: None,
});
}
let mtime: Option<i64> = resp
.headers()
.get("x-file-mtime")
.ok()
.flatten()
.and_then(|s| s.parse::<i64>().ok());
let tp = resp
.text()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js = JsFuture::from(tp)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let text = js
.as_string()
.ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
Ok((text, mtime))
}
/// Save a file's text content (the editor's write path).
///
/// When `force` is false, `expected_mtime` is sent and the server rejects the
/// save with [`ApiError::Conflict`] if the file changed on disk since it was
/// read. When `force` is true the check is skipped (overwrite). Returns the
/// file's new mtime (unix seconds) to anchor the next check.
pub async fn save_content(
root_id: i64,
path: &str,
text: &str,
expected_mtime: Option<i64>,
force: bool,
) -> Result<i64, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let url = format!("{}?action=content", files_url(root_id, path));
let opts = web_sys::RequestInit::new();
opts.set_method("PUT");
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts.set_body_opt_str(Some(text));
let headers = web_sys::Headers::new()
.map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
headers
.set("Content-Type", "text/plain; charset=utf-8")
.map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
if !force {
if let Some(m) = expected_mtime {
headers
.set("X-Expected-Mtime", &m.to_string())
.map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
}
}
opts.set_headers_headers(&headers);
let req = web_sys::Request::new_with_str_and_init(&url, &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if status == 409 {
return Err(ApiError::Conflict);
}
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "could not save file".to_string()),
skipped: None,
});
}
let js = resp
.json()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(js)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
#[derive(Deserialize)]
struct SaveResp {
#[serde(default)]
mtime: Option<i64>,
}
let save: SaveResp =
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?;
Ok(save.mtime.unwrap_or(0))
}
/// Trigger a browser download of a same-origin URL via a temporary anchor.
/// No data is pulled into JS memory — the browser streams it.
pub fn trigger_download(url: &str, filename: &str) {
▾Mweb/src/cm.rs
@@ -55,11 +55,16 @@ pub fn is_text(name: &str) -> bool {
}
/// Create an editor inside `container`.
///
/// `on_update` is an optional JS function called whenever the document changes
/// (used to track the editor's dirty state). If provided, the caller must keep
/// the backing `Closure` alive for as long as the editor exists.
pub fn create(
container: &web_sys::Element,
value: &str,
filename: &str,
editable: bool,
on_update: Option<&JsValue>,
) -> Result<Cm, String> {
let g = global()?;
let opts = js_sys::Object::new();
@@ -69,6 +74,10 @@ pub fn create(
.map_err(|e| err_str(e))?;
js_sys::Reflect::set(&opts, &JsValue::from_str("editable"), &JsValue::from_bool(editable))
.map_err(|e| err_str(e))?;
if let Some(f) = on_update {
js_sys::Reflect::set(&opts, &JsValue::from_str("onUpdate"), f)
.map_err(|e| err_str(e))?;
}
let view = call(
&g,
@@ -88,7 +97,6 @@ pub fn destroy(view: &JsValue) {
/// Replace the whole document (used when (re)loading a file). Used from the
/// editor (milestone 5).
#[allow(dead_code)]
pub fn set_value(view: &JsValue, text: &str) {
let Ok(g) = global() else { return };
let _ = call(&g, "setValue", &[view, &JsValue::from_str(text)]);
@@ -96,8 +104,13 @@ pub fn set_value(view: &JsValue, text: &str) {
/// Read the current document text (used by the editor's save path, milestone
/// 5).
#[allow(dead_code)]
pub fn get_value(view: &JsValue) -> Option<String> {
let g = global().ok()?;
call(&g, "getValue", &[view]).ok()?.as_string()
}
/// Focus the editor (so the caret is ready for typing on open).
pub fn focus(view: &JsValue) {
let Ok(g) = global() else { return };
let _ = call(&g, "focus", &[view]);
}
▾Aweb/src/editor.rs
@@ -0,0 +1,342 @@
//! Text editor (milestone 5): an editable CodeMirror with save, a dirty-state
//! indicator, Ctrl/Cmd+S, and save-time conflict handling (the file changed on
//! disk since it was opened).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use leptos::prelude::*;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::toast::{show, ToastMsg};
/// A text file the user wants to edit.
#[derive(Clone)]
pub struct EditTarget {
pub root_id: i64,
/// Path relative to the root (e.g. "docs/a.txt").
pub path: String,
/// Display name (used for the title + language detection).
pub name: String,
}
/// Read the current document and PUT it, updating the editor's state.
///
/// `force` skips the server's conflict check (used by the "Overwrite" button).
async fn do_save(
target: EditTarget,
force: bool,
cm_view: &Arc<Mutex<Option<JsValue>>>,
loaded_mtime: &Arc<Mutex<Option<i64>>>,
set_dirty: WriteSignal<bool>,
set_saving: WriteSignal<bool>,
set_status: WriteSignal<Option<String>>,
set_conflict: WriteSignal<bool>,
toast: ToastMsg,
refresh: Callback<()>,
) {
let text = match cm_view.lock() {
Ok(g) => g.as_ref().and_then(|v| cm::get_value(v)),
Err(_) => None,
};
let Some(text) = text else {
set_status.set(Some("editor not ready".to_string()));
return;
};
let expected = loaded_mtime.lock().ok().and_then(|g| *g);
set_saving.set(true);
set_status.set(None);
match api::save_content(target.root_id, &target.path, &text, expected, force).await {
Ok(new_mtime) => {
if let Ok(mut g) = loaded_mtime.lock() {
*g = Some(new_mtime);
}
set_dirty.set(false);
set_conflict.set(false);
show(toast, "Saved");
refresh.run(());
}
Err(ApiError::Conflict) => {
set_conflict.set(true);
}
Err(e) => {
set_status.set(Some(e.to_string()));
}
}
set_saving.set(false);
}
/// The editor overlay.
#[component]
pub fn EditorModal(
target: EditTarget,
close: Callback<()>,
toast: ToastMsg,
refresh: Callback<()>,
) -> impl IntoView {
let node = NodeRef::<leptos::html::Div>::new();
let (status, set_status) = signal(Option::<String>::None);
let (dirty, set_dirty) = signal(false);
let (saving, set_saving) = signal(false);
let (conflict, set_conflict) = signal(false);
let (confirm_discard, set_confirm_discard) = signal(false);
// The mtime the loaded content was anchored to (for the conflict check).
let loaded_mtime: Arc<Mutex<Option<i64>>> = Arc::new(Mutex::new(None));
// The live CodeMirror view (destroyed on close).
let cm_view: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
// Set before a programmatic `set_value` so the resulting doc change is not
// counted as a user edit (keeps the dirty state honest).
let suppress = Arc::new(AtomicBool::new(false));
let name = target.name.clone();
// Load the file and create an editable CodeMirror.
{
let t = target.clone();
let st = set_status;
let h = cm_view.clone();
let sup = suppress.clone();
let lt = loaded_mtime.clone();
let sd = set_dirty;
node.on_load(move |el: web_sys::HtmlDivElement| {
spawn_local(async move {
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, mtime)) => {
let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
if sup.swap(false, Ordering::SeqCst) {
return;
}
sd.set(true);
});
// Borrow the JS function while `closure` is alive; the
// synchronous create() only needs it for that call.
let js_ref: &JsValue = closure.as_ref();
match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
Ok(c) => {
cm::focus(&c.view);
let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
let _ = lt.lock().map(|mut g| *g = mtime);
}
Err(e) => st.set(Some(e)),
}
// Keep the Rust callback alive for the editor's lifetime
// (the CM view holds the JS reference; a small, bounded
// leak per open, same pattern as the upload picker).
closure.forget();
}
Err(e) => st.set(Some(e.to_string())),
}
});
});
}
// Destroy the CodeMirror view when the editor closes.
{
let h = cm_view.clone();
on_cleanup(move || {
if let Ok(mut g) = h.lock() {
if let Some(v) = g.take() {
cm::destroy(&v);
}
}
});
}
// Save (with the conflict check).
let save_cb: Callback<()> = {
let t = target.clone();
let cmv = cm_view.clone();
let lt = loaded_mtime.clone();
let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
let (toast, refresh) = (toast, refresh);
Callback::new(move |_| {
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let refresh2 = refresh.clone();
spawn_local(async move {
do_save(t2, false, &cmv2, &lt2, sd, ss, st, sc, toast, refresh2).await;
});
})
};
// Overwrite (skip the conflict check).
let overwrite_cb: Callback<()> = {
let t = target.clone();
let cmv = cm_view.clone();
let lt = loaded_mtime.clone();
let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
let (toast, refresh) = (toast, refresh);
Callback::new(move |_| {
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let refresh2 = refresh.clone();
spawn_local(async move {
do_save(t2, true, &cmv2, &lt2, sd, ss, st, sc, toast, refresh2).await;
});
})
};
// Load the latest on-disk version (discards local edits).
let load_latest_cb: Callback<()> = {
let t = target.clone();
let cmv = cm_view.clone();
let lt = loaded_mtime.clone();
let sup = suppress.clone();
let (sd, st, sc) = (set_dirty, set_status, set_conflict);
Callback::new(move |_| {
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let sup2 = sup.clone();
spawn_local(async move {
st.set(None);
match api::fetch_content_meta(t2.root_id, &t2.path).await {
Ok((text, mtime)) => {
if let Ok(g) = cmv2.lock() {
if let Some(v) = g.as_ref() {
sup2.store(true, Ordering::SeqCst);
cm::set_value(v, &text);
}
}
if let Ok(mut g) = lt2.lock() {
*g = mtime;
}
sd.set(false);
sc.set(false);
}
Err(e) => st.set(Some(e.to_string())),
}
});
})
};
// Close, prompting first if there are unsaved changes.
let request_close: Callback<()> = {
let close = close.clone();
let d = dirty;
let scd = set_confirm_discard;
Callback::new(move |_| {
if d.get() {
scd.set(true);
} else {
close.run(());
}
})
};
// Ctrl/Cmd+S saves (scoped to the editor's lifetime).
{
let cb = save_cb.clone();
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
ev.prevent_default();
cb.run(());
}
});
}
view! {
<div class="modal-overlay editor-overlay" on:click=move |_| request_close.run(())>
<div class="modal editor-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="editor-head">
<span class="editor-title">
{name}
{move || if dirty.get() { " •" } else { " " }}
</span>
<div class="editor-actions">
<button
class="btn btn-primary"
disabled=move || !dirty.get() || saving.get()
on:click=move |_| save_cb.run(())
>
{move || if saving.get() { "Saving…" } else { "Save" }}
</button>
<button
class="preview-close"
title="Close"
on:click=move |_| request_close.run(())
>
"×"
</button>
</div>
</div>
<div class="editor-body">
<div node_ref=node class="cm-container cm-edit"></div>
{move || {
let err = status.get();
match err {
Some(e) => view! { <div class="cm-error">{e}</div> }
.into_view()
.into_any(),
None => view! {}.into_any(),
}
}}
</div>
</div>
// Conflict: the file changed on disk since it was opened.
{move || {
if conflict.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"File changed on disk"</h2>
<p class="modal-message">
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version."
</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| load_latest_cb.run(())>
"Load latest"
</button>
<button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
"Overwrite"
</button>
</div>
</div>
</div>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
// Unsaved-changes confirmation.
{move || {
if confirm_discard.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"Unsaved changes"</h2>
<p class="modal-message">"Discard your changes and close the editor?"</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_discard.set(false)>
"Keep editing"
</button>
<button class="btn btn-danger" on:click=move |_| {
set_confirm_discard.set(false);
close.run(());
}>
"Discard"
</button>
</div>
</div>
</div>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</div>
}
}
▾Mweb/src/main.rs
@@ -2,6 +2,7 @@ mod api;
mod app;
mod cm;
mod components;
mod editor;
mod preview;
mod router;
mod util;
▾Mweb/src/preview.rs
@@ -147,7 +147,7 @@ fn TextPreview(target: PreviewTarget) -> impl IntoView {
let h = h.clone();
spawn_local(async move {
match api::fetch_content(t.root_id, &t.path).await {
Ok(text) => match cm::create(&el, &text, &t.name, false) {
Ok(text) => match cm::create(&el, &text, &t.name, false, None) {
Ok(c) => {
let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
}
▾Mweb/src/views/browser.rs
@@ -5,8 +5,10 @@ use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::cm;
use crate::components::icon::{icon_for, Icon, IconName};
use crate::components::toast::{show, ToastMsg};
use crate::editor::{EditTarget, EditorModal};
use crate::router::{navigate, Location};
use crate::util::{format_date, format_size, ViewMode};
use crate::preview::{preview_kind, PreviewKind, PreviewModal, PreviewTarget};
@@ -48,6 +50,7 @@ pub fn Browser(
let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
let (preview, set_preview) = signal(Option::<(PreviewTarget, PreviewKind)>::None);
let (editor, set_editor) = signal(Option::<EditTarget>::None);
// Long-lived owner for callbacks created inside short-lived scopes
// (context-menu event handlers); otherwise they get disposed with the
// menu and the open dialog panics on submit.
@@ -105,6 +108,7 @@ pub fn Browser(
refresh=fetch
toast=toast
set_dialog=set_dialog
set_editor=set_editor
owner=me_owner
/>
<DialogView dialog=dialog set_dialog=set_dialog/>
@@ -123,6 +127,22 @@ pub fn Browser(
None => view! {}.into_any(),
}
}}
{move || {
let ed = editor.get();
match ed {
Some(t) => view! {
<EditorModal
target=t
close=Callback::new(move |_| set_editor.set(None))
toast=toast
refresh=fetch.clone()
/>
}
.into_view()
.into_any(),
None => view! {}.into_any(),
}
}}
</div>
}
}
@@ -498,6 +518,7 @@ fn CtxMenuView(
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
set_editor: WriteSignal<Option<EditTarget>>,
owner: Owner,
) -> impl IntoView {
// Close on any click or Escape.
@@ -544,6 +565,12 @@ fn CtxMenuView(
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.name.clone())
.unwrap_or_default();
// The id of the root that is actually shown (resolves the single-root
// default location, where `loc.root_id` is `None`).
let eff_root_id = me_now
.as_ref()
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.id);
// (label, action, disabled-hint)
let items: Vec<(String, Option<Callback<()>>, Option<String>)> = match &m.entry {
@@ -571,16 +598,29 @@ fn CtxMenuView(
let l_dl = loc;
v.push((
"Download".into(),
Some(action_download(&e_dl, l_dl, set_dialog, owner.clone())),
Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, owner.clone())),
None,
));
// Editing is only offered for text/code files.
if !e2.is_dir && cm::is_text(&e2.name) {
let e_ed = e2.clone();
let l_ed = loc;
v.push((
"Edit".into(),
rw_action(
is_rw,
action_edit(&e_ed, eff_root_id, l_ed, set_editor, owner.clone()),
),
Some("Read-only folder".into()),
));
}
let e_rename = e2.clone();
let l_rename = loc;
v.push((
"Rename".into(),
rw_action(
is_rw,
action_rename(&e_rename, l_rename, refresh, toast, set_dialog, owner.clone()),
action_rename(&e_rename, eff_root_id, l_rename, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
@@ -594,6 +634,7 @@ fn CtxMenuView(
action_move_copy(
Op::Move,
&e_move,
eff_root_id,
m_move,
l_move,
refresh,
@@ -612,6 +653,7 @@ fn CtxMenuView(
Some(action_move_copy(
Op::Copy,
&e3,
eff_root_id,
m_copy,
l_copy,
refresh,
@@ -641,7 +683,7 @@ fn CtxMenuView(
"Delete".into(),
rw_action(
is_rw,
action_delete(&e4, l_del, refresh, toast, set_dialog, owner.clone()),
action_delete(&e4, eff_root_id, l_del, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
@@ -654,7 +696,7 @@ fn CtxMenuView(
"New folder".into(),
rw_action(
is_rw,
action_new_folder(l_nf, refresh, toast, set_dialog, owner.clone()),
action_new_folder(eff_root_id, l_nf, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
@@ -663,7 +705,7 @@ fn CtxMenuView(
"Upload files".into(),
rw_action(
is_rw,
action_upload(true, false, l_up1, refresh, toast, set_dialog),
action_upload(true, false, eff_root_id, l_up1, refresh, toast, set_dialog),
),
Some("Read-only folder".into()),
));
@@ -672,7 +714,7 @@ fn CtxMenuView(
"Upload folder".into(),
rw_action(
is_rw,
action_upload(true, true, l_up2, refresh, toast, set_dialog),
action_upload(true, true, eff_root_id, l_up2, refresh, toast, set_dialog),
),
Some("Read-only folder".into()),
));
@@ -751,6 +793,7 @@ fn is_conflict(e: &api::ApiError) -> bool {
}
fn action_new_folder(
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
@@ -758,11 +801,8 @@ fn action_new_folder(
owner: Owner,
) -> Callback<()> {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.clone();
set_dialog.set(Some(Dialog::Prompt {
title: "New folder".into(),
@@ -794,6 +834,7 @@ fn action_new_folder(
fn action_rename(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
@@ -802,11 +843,8 @@ fn action_rename(
) -> Callback<()> {
let name = entry.name.clone();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.clone();
let full = join_path(&dir, &name);
set_dialog.set(Some(Dialog::Prompt {
@@ -862,6 +900,7 @@ fn action_rename(
fn action_delete(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
@@ -871,11 +910,8 @@ fn action_delete(
let name = entry.name.clone();
let is_dir = entry.is_dir;
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.clone();
let full = join_path(&dir, &name);
let message = if is_dir {
@@ -912,17 +948,15 @@ fn action_delete(
fn action_upload(
multiple: bool,
directory: bool,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
) -> Callback<()> {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.join("/");
let dir1 = dir.clone();
api::pick_files(multiple, directory, move |files| {
@@ -972,6 +1006,7 @@ fn action_upload(
fn action_move_copy(
op: Op,
entry: &Entry,
root_id: Option<i64>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
@@ -984,10 +1019,7 @@ fn action_move_copy(
Callback::new(move |_| {
let loc = loc.get();
let me = me.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let Some(root_id) = root_id else { return };
let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
let dir = loc.path.clone();
let full = join_path(&dir, &name);
@@ -1059,6 +1091,7 @@ fn action_move_copy(
fn action_download(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
@@ -1067,11 +1100,8 @@ fn action_download(
let is_dir = entry.is_dir;
owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let full = join_path(&loc.path, &name);
if is_dir {
set_dialog.set(Some(Dialog::DownloadFormat {
@@ -1087,6 +1117,29 @@ fn action_download(
})
}
/// Open the text editor for a file (milestone 5).
fn action_edit(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
set_editor: WriteSignal<Option<EditTarget>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let full = join_path(&loc.path, &name);
set_editor.set(Some(EditTarget {
root_id,
path: full,
name: name.clone(),
}));
})
})
}
fn action_info(
entry: &Entry,
loc: ReadSignal<Location>,