UI: create-file action, robust dialog errors, context-menu placement
New file action - POST ?action=create-file (fs::create_file via O_EXCL create_new: no TOCTOU, does not follow symlinks), client API, menu item + icon (official material-symbols note-add path), EN/DE/FR strings - err_bad_post texts updated to mention create-file; ACTION_CREATE_FILE documented Dialogs - single text-input dialog concept: Prompt merged into PromptOp, which runs the async op, shows failures inline and stays open (busy state, error clears on typing); rename converted, Dialog::Prompt deleted - toast::show_error + .toast-error styling; all error call sites switched - fix menu/topbar action callbacks bound to the short-lived menu owner, which made dialogs opened from the menu panic on submit - share-creation stuck at 'Creating…': missing i18n key 'done' panicked the completion view; key added + new test all_k_constants_exist_in_en (every k:: constant must exist in the EN table) - PromptOpDialog: drop submit_fn clone noise (closure is now Copy) Context menu - replace fixed 220x300 clamp with measure-and-flip: menu paints at the cursor, then flips above/left when it would overflow the viewport, clamping to the edges as a fallback Browser rows/tiles - drop mousedown prevent_default (broke click-then-Enter); rows keep click focus, use user-select: none instead; focus ring stays inset (outline-offset: -2px) so it is never clipped Other - editor: stop leaking a CodeMirror listener closure per open (closure stored and dropped after cm.destroy releases the JS reference) - pick_files: document the element/listener/closure leak accurately - share page brand links to #/ like the app shell Co-Authored-By: Qwen3.8 27b
Mapi-types/src/lib.rs
@@ -36,6 +36,9 @@ pub const ACTION_CONTENT: &str = "content";
/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
/// POST route also carries uploads and mutations.
pub const ACTION_MKDIR: &str = "mkdir";
/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
/// like `mkdir`, for the same reason.
pub const ACTION_CREATE_FILE: &str = "create-file";
/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
pub const P_FORMAT: &str = "format";
/// `?share=<token>` — authenticate file calls with a public share token.
Mserver/src/api/files.rs
@@ -584,8 +584,16 @@ async fn dispatch_inner(
.await?
.into_response());
}
if action_param(req.uri()).as_deref() == Some(api_types::ACTION_MKDIR) {
return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
match action_param(req.uri()).as_deref() {
Some(api_types::ACTION_MKDIR) => {
return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
}
Some(api_types::ACTION_CREATE_FILE) => {
return Ok(create_file(state, auth, root_id, req_rel)
.await?
.into_response());
}
_ => {}
}
Err(ApiError::localized(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
@@ -594,6 +602,38 @@ async fn dispatch_inner(
))
}
// ---------------------------------------------------------------------------
// create file
// ---------------------------------------------------------------------------
/// Create an empty file in a writable root.
async fn create_file(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"a file name is required",
"err_file_name_required",
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::create_file(&server_root, &root_rel, &rel))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
Ok(Json(OkResp { ok: true }))
}
// ---------------------------------------------------------------------------
// mkdir
// ---------------------------------------------------------------------------
Mserver/src/fs.rs
@@ -375,6 +375,17 @@ pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), Fs
Ok(())
}
/// Create an empty file. The parent must exist; the file must not.
pub fn create_file(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
let full = resolve_path_or_new(server_root, root_rel, req_rel)?;
// create_new = O_EXCL: fails on an existing path, does not follow a symlink.
std::fs::File::create_new(&full).map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => FsError::Conflict,
_ => io_err(e, &full),
})?;
Ok(())
}
/// Resolve a path that does not need to exist yet, but whose *parent* must.
fn resolve_path_or_new(
server_root: &Path,
Mweb/app.css
@@ -636,6 +636,7 @@ button:disabled {
padding: 16px 10px 12px;
text-align: center;
cursor: pointer;
user-select: none;
}
.tile:hover {
@@ -672,6 +673,7 @@ button:disabled {
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 14px;
user-select: none;
}
.row:last-child {
@@ -853,6 +855,12 @@ button:disabled {
max-width: 320px;
}
.toast-error {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
border-color: var(--danger-border);
color: var(--danger);
}
/* ---- modals ---- */
@@ -897,6 +905,16 @@ button:disabled {
margin-bottom: 16px;
}
.modal-label.error,
.dialog-error {
color: var(--danger);
font-size: 13px;
}
.dialog-error {
margin-top: 8px;
}
.modal-message {
white-space: pre-line;
font-size: 14px;
@@ -1564,9 +1582,10 @@ button:disabled {
font-size: 16px;
}
/* Visible keyboard focus on selectable file/folder entries. */
/* Visible keyboard focus on selectable file/folder entries. The ring is
inset (negative offset) so the scroll container never clips it. */
.tile:focus-visible,
.row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
outline-offset: -2px;
}
Mweb/src/api.rs
@@ -11,9 +11,10 @@ use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW, ADMIN_SETTINGS, ADMIN_USERS,
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES,
Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root, SHARE, SHARES, Settings, UpdateUser,
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare,
CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root,
SHARE, SHARES, Settings, UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
@@ -181,6 +182,19 @@ pub fn list_files(
request("GET", files_url(root_id, path), None::<()>)
}
/// Create an empty file. `path` is relative to the root (may contain
/// subfolders); the file must not exist yet.
pub fn create_file(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
let url = append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_CREATE_FILE}"),
);
request("POST", url, None::<()>)
}
/// Create a folder. `path` is relative to the root (may contain subfolders).
pub fn mkdir(
root_id: i64,
@@ -607,9 +621,15 @@ pub fn pick_files(
let _ = input.set_attribute("webkitdirectory", "");
}
// The listener keeps the JS callback alive while the input exists;
// detach from Rust (the input is removed after the dialog is used).
// A cancelled dialog leaks the hidden input until reload — acceptable.
// Known small leak, deliberate: the input holds the listener, the
// listener holds this closure, and the closure captures the input — a
// cycle that nothing frees. `forget()` detaches the Rust side, so the
// element + JS function + closure (~1 KB) survive until reload even
// when the dialog is used (not only when cancelled). Dropping the
// closure from Rust while the JS listener still references it would
// leave a dangling callback, and a closure cannot drop itself; a clean
// fix needs the caller to own it (e.g. a `StoredValue` released in
// `on_cleanup`), which is not worth it at this size.
let input2 = input.clone();
let closure = Closure::<dyn FnMut()>::new(move || {
let mut files: Vec<(String, web_sys::File)> = Vec::new();
Mweb/src/components/toast.rs
@@ -3,12 +3,15 @@ use std::time::Duration;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
/// Context type: read/write access to the current toast message.
/// Context type: read/write access to the current toast.
///
/// The state is `(message, is_error)`: errors get their own styling so they
/// do not read like routine confirmations.
#[derive(Copy, Clone)]
pub struct ToastMsg(pub RwSignal<Option<String>>);
pub struct ToastMsg(pub RwSignal<Option<(String, bool)>>);
pub fn provide_toast() -> ToastMsg {
let toast = RwSignal::new(Option::<String>::None);
let toast = RwSignal::new(Option::<(String, bool)>::None);
let t = ToastMsg(toast);
provide_context(t);
t
@@ -16,11 +19,19 @@ pub fn provide_toast() -> ToastMsg {
/// Show a toast that disappears after ~3 seconds.
pub fn show(toast: ToastMsg, msg: impl Into<String>) {
let msg = msg.into();
toast.0.set(Some(msg.clone()));
set(toast, msg.into(), false);
}
/// Show a failure toast (danger styling).
pub fn show_error(toast: ToastMsg, msg: impl Into<String>) {
set(toast, msg.into(), true);
}
fn set(toast: ToastMsg, msg: String, is_error: bool) {
toast.0.set(Some((msg.clone(), is_error)));
spawn_local(async move {
gloo_timers::future::sleep(Duration::from_secs(3)).await;
if toast.0.get().as_deref() == Some(msg.as_str()) {
if toast.0.get().as_ref().map(|(m, _)| m.as_str()) == Some(msg.as_str()) {
toast.0.set(None);
}
});
@@ -30,7 +41,9 @@ pub fn show(toast: ToastMsg, msg: impl Into<String>) {
pub fn ToastView(toast: ToastMsg) -> impl IntoView {
view! {
<Show when=move || toast.0.get().is_some()>
<div class="toast">{move || toast.0.get().unwrap_or_default()}</div>
<div class="toast" class:toast-error=move || toast.0.get().is_some_and(|(_, e)| e)>
{move || toast.0.get().map(|(m, _)| m).unwrap_or_default()}
</div>
</Show>
}
}
Mweb/src/editor.rs
@@ -100,6 +100,11 @@ pub fn Editor(
let loaded_mtime = StoredValue::new(Option::<i64>::None);
// The live CodeMirror view (destroyed on close).
let cm_view = StoredValue::new_local(Option::<JsValue>::None);
// The change-listener closure backing the editable CodeMirror. CodeMirror
// holds a JS reference to it for the view's lifetime, so we keep the Rust
// side here (rather than `forget()`-ing it) and free it in the cleanup
// below — after `cm::destroy` releases CodeMirror's reference.
let cm_change = StoredValue::new_local(Option::<Closure<dyn FnMut(JsValue)>>::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 = StoredValue::new(false);
@@ -138,14 +143,13 @@ pub fn Editor(
Ok(c) => {
cm::focus(&c.view);
cm_view.set_value(Some(c.view.clone()));
cm_change.set_value(Some(closure));
loaded_mtime.set_value(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();
// On failure the closure is simply dropped here; on
// success it is owned by `cm_change` until cleanup.
}
Err(e) => st.set(Some(e.to_string())),
}
@@ -153,11 +157,14 @@ pub fn Editor(
});
}
// Destroy the CodeMirror view when the editor closes.
// Destroy the CodeMirror view when the editor closes. Order matters:
// `destroy` must release CodeMirror's JS reference before we drop the
// Rust-side owner of the change-listener closure.
on_cleanup(move || {
if let Some(v) = cm_view.try_update_value(Option::take).flatten() {
cm::destroy(&v);
}
cm_change.try_update_value(Option::take);
});
// Save and overwrite differ only in the conflict check (`force`).
Mweb/src/i18n.rs
@@ -173,6 +173,7 @@ pub mod k {
pub const INFO: &str = "info";
pub const DELETE: &str = "delete";
pub const NEW_FOLDER: &str = "new_folder";
pub const NEW_FILE: &str = "new_file";
pub const UPLOAD_FILES: &str = "upload_files";
pub const UPLOAD_FOLDER: &str = "upload_folder";
pub const READ_ONLY_HINT: &str = "read_only_hint";
@@ -181,6 +182,7 @@ pub mod k {
// Dialogs: prompt / confirm
pub const FOLDER_NAME: &str = "folder_name";
pub const FILE_NAME: &str = "file_name";
pub const CREATE: &str = "create";
pub const RENAME_TITLE: &str = "rename_title";
pub const NEW_NAME: &str = "new_name";
@@ -217,6 +219,7 @@ pub mod k {
// Toasts
pub const FOLDER_CREATED: &str = "folder_created";
pub const FILE_CREATED: &str = "file_created";
pub const NAME_TAKEN: &str = "name_taken";
pub const RENAMED: &str = "renamed";
pub const DELETED: &str = "deleted";
@@ -393,6 +396,7 @@ pub mod k {
pub const ERR_INVALID_LANGUAGE: &str = "err_invalid_language";
pub const ERR_BAD_POST: &str = "err_bad_post";
pub const ERR_FOLDER_NAME_REQUIRED: &str = "err_folder_name_required";
pub const ERR_FILE_NAME_REQUIRED: &str = "err_file_name_required";
pub const ERR_NEW_NAME_REQUIRED: &str = "err_new_name_required";
pub const ERR_DST_REQUIRED: &str = "err_dst_required";
pub const ERR_PATH_REQUIRED: &str = "err_path_required";
@@ -447,16 +451,19 @@ const EN: &[(&str, &str)] = &[
("rename", "Rename"),
("move", "Move"),
("copy", "Copy"),
("done", "Done"),
("share", "Share"),
("info", "Info"),
("delete", "Delete"),
("new_folder", "New folder"),
("new_file", "New file"),
("upload_files", "Upload files"),
("upload_folder", "Upload folder"),
("read_only_hint", "Read-only folder"),
("read_only_badge", "Read-only"),
("more_actions", "More actions"),
("folder_name", "Folder name"),
("file_name", "File name"),
("create", "Create"),
("rename_title", "Rename “{}”"),
("new_name", "New name"),
@@ -501,6 +508,7 @@ const EN: &[(&str, &str)] = &[
("location", "Location"),
("close", "Close"),
("folder_created", "Folder created"),
("file_created", "File created"),
("name_taken", "Name already in use"),
("renamed", "Renamed"),
("deleted", "Deleted"),
@@ -720,9 +728,10 @@ const EN: &[(&str, &str)] = &[
(k::ERR_INVALID_LANGUAGE, "invalid language tag"),
(
k::ERR_BAD_POST,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir/create-file",
),
(k::ERR_FOLDER_NAME_REQUIRED, "a folder name is required"),
(k::ERR_FILE_NAME_REQUIRED, "a file name is required"),
(k::ERR_NEW_NAME_REQUIRED, "new_name is required"),
(k::ERR_DST_REQUIRED, "dst_root_id is required"),
(k::ERR_PATH_REQUIRED, "a path inside the folder is required"),
@@ -788,16 +797,19 @@ const DE: &[(&str, &str)] = &[
("rename", "Umbenennen"),
("move", "Verschieben"),
("copy", "Kopieren"),
("done", "Fertig"),
("share", "Teilen"),
("info", "Info"),
("delete", "Löschen"),
("new_folder", "Neuer Ordner"),
("new_file", "Neue Datei"),
("upload_files", "Dateien hochladen"),
("upload_folder", "Ordner hochladen"),
("read_only_hint", "Schreibgeschützter Ordner"),
("read_only_badge", "Schreibgeschützt"),
("more_actions", "Weitere Aktionen"),
("folder_name", "Ordnername"),
("file_name", "Dateiname"),
("create", "Erstellen"),
("rename_title", "„{}“ umbenennen"),
("new_name", "Neuer Name"),
@@ -845,6 +857,7 @@ const DE: &[(&str, &str)] = &[
("location", "Ort"),
("close", "Schließen"),
("folder_created", "Ordner erstellt"),
("file_created", "Datei erstellt"),
("name_taken", "Name wird bereits verwendet"),
("renamed", "Umbenannt"),
("deleted", "Gelöscht"),
@@ -1106,12 +1119,13 @@ const DE: &[(&str, &str)] = &[
(k::ERR_BAD_BODY, "ungültiger Anfrageinhalt"),
(
k::ERR_BAD_POST,
"POST erwartet einen Multipart-Upload, eine JSON-Mutation oder ?action=mkdir",
"POST erwartet einen Multipart-Upload, eine JSON-Mutation oder ?action=mkdir/create-file",
),
(
k::ERR_FOLDER_NAME_REQUIRED,
"ein Ordnernamen ist erforderlich",
),
(k::ERR_FILE_NAME_REQUIRED, "ein Dateiname ist erforderlich"),
(k::ERR_NEW_NAME_REQUIRED, "new_name ist erforderlich"),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
(
@@ -1186,16 +1200,19 @@ const FR: &[(&str, &str)] = &[
("rename", "Renommer"),
("move", "Déplacer"),
("copy", "Copier"),
("done", "Terminé"),
("share", "Partager"),
("info", "Infos"),
("delete", "Supprimer"),
("new_folder", "Nouveau dossier"),
("new_file", "Nouveau fichier"),
("upload_files", "Téléverser des fichiers"),
("upload_folder", "Téléverser un dossier"),
("read_only_hint", "Dossier en lecture seule"),
("read_only_badge", "Lecture seule"),
("more_actions", "Plus d'actions"),
("folder_name", "Nom du dossier"),
("file_name", "Nom du fichier"),
("create", "Créer"),
("rename_title", "Renommer « {} »"),
("new_name", "Nouveau nom"),
@@ -1243,6 +1260,7 @@ const FR: &[(&str, &str)] = &[
("location", "Emplacement"),
("close", "Fermer"),
("folder_created", "Dossier créé"),
("file_created", "Fichier créé"),
("name_taken", "Ce nom est déjà utilisé"),
("renamed", "Renommé"),
("deleted", "Supprimé"),
@@ -1522,9 +1540,10 @@ const FR: &[(&str, &str)] = &[
(k::ERR_INVALID_LANGUAGE, "balise de langue invalide"),
(
k::ERR_BAD_POST,
"POST attend un téléversement multipart, une mutation JSON ou ?action=mkdir",
"POST attend un téléversement multipart, une mutation JSON ou ?action=mkdir/create-file",
),
(k::ERR_FOLDER_NAME_REQUIRED, "un nom de dossier est requis"),
(k::ERR_FILE_NAME_REQUIRED, "un nom de fichier est requis"),
(k::ERR_NEW_NAME_REQUIRED, "new_name est requis"),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(k::ERR_PATH_REQUIRED, "un chemin dans le dossier est requis"),
@@ -1601,4 +1620,43 @@ mod tests {
}
}
}
/// The parity test above only compares the tables against each other, so
/// a key missing from *all* of them (e.g. a `k` constant added without
/// its translations) slips through. Scan the `k` module's source for
/// `pub const NAME: &str = "key";` and require every key in `EN`.
#[test]
fn all_k_constants_exist_in_en() {
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/i18n.rs"))
.expect("read i18n.rs");
let k_start = src.find("pub mod k {").expect("k module");
// The k module's closing brace is the first column-0 `}` after it.
let k_end = k_start + src[k_start..].find("\n}").expect("k module end");
// Lines look like: `pub const NAME: &str = "key";`
let parse_line = |l: &str| {
let l = l.trim();
let mut parts = l.splitn(3, char::is_whitespace);
let _kw = parts.next()?;
let _const_kw = parts.next()?;
let rest = parts.next()?; // `NAME: &str = "key";`
let (name_ty, value) = rest.split_once('=')?;
let ty = name_ty.rsplit_once(':')?.1.trim();
let val = value.trim().trim_end_matches(';').trim().trim_matches('"');
(ty == "&str").then_some(val.to_string())
};
let keys: Vec<String> = src[k_start..k_end]
.lines()
.filter_map(parse_line)
.filter(|k| !k.is_empty())
.collect();
assert!(keys.len() > 100, "scan found only {} keys", keys.len());
let missing: Vec<&String> = keys
.iter()
.filter(|k| EN.iter().all(|(t, _)| *t != k.as_str()))
.collect();
assert!(
missing.is_empty(),
"k constants without an EN translation: {missing:?}"
);
}
}
Mweb/src/icons.rs
@@ -45,6 +45,8 @@ pub enum IconName {
UploadFolder,
/// material-symbols:create-new-folder
NewFolder,
/// material-symbols:note-add
NewFile,
/// material-symbols:download
Download,
/// material-symbols:open-in-full
@@ -143,6 +145,9 @@ impl IconName {
Self::NewFolder => {
r#"<path fill="currentColor" d="M14 16h2v-2h2v-2h-2v-2h-2v2h-2v2h2zM4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z"/>"#
}
Self::NewFile => {
r#"<path fill="currentColor" d="M11 18h2v-3h3v-2h-3v-3h-2v3H8v2h3zm-5 4q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z"/>"#
}
Self::Download => {
r#"<path fill="currentColor" d="m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"/>"#
}
Mweb/src/views/admin.rs
@@ -15,7 +15,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::components::toast::{ToastMsg, show, show_error};
use crate::icons::IconName;
// ---------------------------------------------------------------------------
@@ -177,7 +177,7 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
}
match api::get_admin_settings().await {
Ok(s) => set.set(Some(s.allow_writable_shares)),
Err(e) => show(toast2, e.to_string()),
Err(e) => show_error(toast2, e.to_string()),
}
});
}
@@ -338,7 +338,7 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
spawn_local(async move {
match api::list_admin_users().await {
Ok(u) => set.set(Some(u)),
Err(e) => show(toast2, e.to_string()),
Err(e) => show_error(toast2, e.to_string()),
}
});
};
@@ -376,7 +376,7 @@ pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) ->
write.set(Some(cur));
}
} else {
show(toast2, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
show_error(toast2, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
}
});
};
@@ -648,7 +648,7 @@ fn UserForm(
close2.run(());
}
Err(e) => {
show(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
show_error(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
set_busy.set(false);
}
}
Mweb/src/views/browser.rs
@@ -8,7 +8,7 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::components::toast::{ToastMsg, show, show_error};
use crate::editor::EditTarget;
use crate::i18n;
use crate::icons::{IconName, icon_for};
@@ -695,7 +695,6 @@ fn menu_items(
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false);
let own = || owner.clone();
match entry {
Some(e) => {
@@ -721,19 +720,19 @@ fn menu_items(
v.push(MenuItem::new(
IconName::OpenInNew,
i18n::t(i18n::k::OPEN_IN_NEW_TAB),
action_open_tab(e, root_id, loc, own()),
action_open_tab(e, root_id, loc, owner.clone()),
));
}
v.push(MenuItem::new(
IconName::Download,
i18n::t(i18n::k::DOWNLOAD),
action_download(e, root_id, loc, set_dialog, toast, own()),
action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
));
v.push(MenuItem::rw(
IconName::Rename,
i18n::t(i18n::k::RENAME),
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
));
v.push(MenuItem::rw(
IconName::Move,
@@ -748,7 +747,7 @@ fn menu_items(
refresh,
toast,
set_dialog,
own(),
owner.clone(),
),
));
// Copying only writes at the destination, so a read-only source
@@ -765,7 +764,7 @@ fn menu_items(
refresh,
toast,
set_dialog,
own(),
owner.clone(),
),
));
if loc_now.share_token.is_none() {
@@ -779,20 +778,20 @@ fn menu_items(
allow_writable_shares,
is_rw,
set_dialog,
own(),
owner.clone(),
),
));
}
v.push(MenuItem::new(
IconName::Info,
i18n::t(i18n::k::INFO),
action_info(e, loc, root_name, set_dialog, own()),
action_info(e, loc, root_name, set_dialog, owner.clone()),
));
v.push(MenuItem::rw(
IconName::Delete,
i18n::t(i18n::k::DELETE),
is_rw,
action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
action_delete(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
));
v
}
@@ -801,7 +800,13 @@ fn menu_items(
IconName::NewFolder,
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
action_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()),
),
MenuItem::rw(
IconName::NewFile,
i18n::t(i18n::k::NEW_FILE),
is_rw,
action_new_file(root_id, loc, refresh, toast, set_dialog, owner.clone()),
),
MenuItem::rw(
IconName::Upload,
@@ -846,6 +851,37 @@ fn CtxMenuView(
);
}
// The menu first paints at the cursor point; after mount, this measures
// its actual size and flips it above/left of the cursor when it would
// overflow the viewport (native context-menu behavior), clamping to the
// viewport edges as a fallback.
let menu_ref = NodeRef::<leptos::html::Div>::new();
Effect::new(move |_| {
let Some(m) = ctx.get() else {
return;
};
let Some(node) = menu_ref
.get()
.and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
else {
return;
};
let rect = node.get_bounding_client_rect();
let (w, h) = (rect.width() as i32, rect.height() as i32);
let (vw, vh) = web_sys::window()
.and_then(|w| {
let vw = w.inner_width().ok()?.as_f64()?;
let vh = w.inner_height().ok()?.as_f64()?;
Some((vw, vh))
})
.unwrap_or((f64::MAX, f64::MAX));
let (vw, vh) = (vw as i32, vh as i32);
let x = if m.x + w > vw { m.x - w } else { m.x }.clamp(0, (vw - w).max(0));
let y = if m.y + h > vh { m.y - h } else { m.y }.clamp(0, (vh - h).max(0));
let _ = node.style().set_property("left", &format!("{x}px"));
let _ = node.style().set_property("top", &format!("{y}px"));
});
view! {
{move || {
let Some(m) = ctx.get() else {
@@ -853,19 +889,6 @@ fn CtxMenuView(
view! {}.into_any()
};
};
// Keep the menu inside the viewport.
let (max_x, max_y) = web_sys::window()
.and_then(|w| {
w.inner_width()
.ok()
.and_then(|v| v.as_f64())
.zip(w.inner_height().ok().and_then(|v| v.as_f64()))
})
.map(|(x, y)| (x as i32 - 220, y as i32 - 300))
.unwrap_or((m.x, m.y));
let x = m.x.min(max_x).max(0);
let y = m.y.min(max_y).max(0);
let items = menu_items(
m.entry.as_ref(),
me,
@@ -878,7 +901,11 @@ fn CtxMenuView(
);
view! {
<div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
<div
class="ctx-menu"
node_ref=menu_ref
style=format!("left:{}px;top:{}px", m.x, m.y)
>
{items.into_iter().map(|item| {
view! { <CtxItem item=item/> }
}).collect::<Vec<_>>()}
@@ -1180,7 +1207,7 @@ fn run_with_overwrite_retry(
.map(<[String]>::to_vec)
.or_else(|| is_conflict(&e).then_some(names));
let Some(files) = conflicting else {
show(toast, e.to_string());
show_error(toast, e.to_string());
return;
};
set_dialog.set(Some(Dialog::Conflict {
@@ -1191,7 +1218,7 @@ fn run_with_overwrite_retry(
spawn_local(async move {
match retry(true).await {
Ok(()) => done(ok_msg, refresh, toast),
Err(e) => show(toast, e.to_string()),
Err(e) => show_error(toast, e.to_string()),
}
});
}),
@@ -1211,28 +1238,70 @@ fn action_new_folder(
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.clone();
set_dialog.set(Some(Dialog::Prompt {
let toast2 = toast;
let refresh2 = refresh;
set_dialog.set(Some(Dialog::PromptOp {
title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
initial: String::new(),
submit: i18n::t(i18n::k::CREATE).to_string(),
on_submit: owner.with(|| {
run: owner.with(|| {
Callback::new(move |name: String| {
let name = name.trim().to_string();
if name.is_empty() {
return;
}
let full = join_path(&dir, &name);
set_dialog.set(None);
spawn_local(async move {
match api::mkdir(root_id, &full).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::FOLDER_CREATED));
refresh.run(());
}
Err(e) => show(toast, e.to_string()),
}
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
api::mkdir(root_id, &full)
.await
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
refresh2.run(());
})
}),
}));
})
}
fn action_new_file(
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.clone();
let toast2 = toast;
let refresh2 = refresh;
set_dialog.set(Some(Dialog::PromptOp {
title: i18n::t(i18n::k::NEW_FILE).to_string(),
label: i18n::t(i18n::k::FILE_NAME).to_string(),
initial: String::new(),
submit: i18n::t(i18n::k::CREATE).to_string(),
run: owner.with(|| {
Callback::new(move |name: String| {
let full = join_path(&dir, &name);
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
api::create_file(root_id, &full)
.await
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast2, i18n::t(i18n::k::FILE_CREATED));
refresh2.run(());
})
}),
}));
@@ -1254,36 +1323,27 @@ fn action_rename(
let loc = loc.get();
let dir = loc.path.clone();
let full = join_path(&dir, &name);
set_dialog.set(Some(Dialog::Prompt {
set_dialog.set(Some(Dialog::PromptOp {
title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
label: i18n::t(i18n::k::NEW_NAME).to_string(),
initial: name.clone(),
submit: i18n::t(i18n::k::RENAME).to_string(),
on_submit: owner.with(|| {
run: owner.with(|| {
Callback::new(move |new_name: String| {
let new_name = new_name.trim().to_string();
if new_name.is_empty() {
return;
}
set_dialog.set(None);
let full = full.clone();
let target = new_name.clone();
run_with_overwrite_retry(
move |overwrite| {
let (full, new_name) = (full.clone(), new_name.clone());
Box::pin(async move {
api::rename_item(root_id, &full, new_name, overwrite)
.await
.map(|_| ())
})
},
i18n::t(i18n::k::NAME_TAKEN),
vec![target],
i18n::t(i18n::k::RENAMED),
refresh,
toast,
set_dialog,
);
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
api::rename_item(root_id, &full, new_name, false)
.await
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast, i18n::t(i18n::k::RENAMED));
refresh.run(());
})
}),
}));
@@ -1326,7 +1386,7 @@ fn action_delete(
show(toast, i18n::t(i18n::k::DELETED));
refresh.run(());
}
Err(e) => show(toast, e.to_string()),
Err(e) => show_error(toast, e.to_string()),
}
});
})
Mweb/src/views/dialogs.rs
@@ -39,13 +39,20 @@ impl Op {
/// The single active dialog. The browser renders whichever is `Some`.
#[derive(Clone)]
pub enum Dialog {
/// Text input (new folder, rename).
Prompt {
/// Text input (new folder, new file, rename). Submit runs an async
/// operation; failures are shown inside the dialog, which stays open,
/// and on success it closes.
PromptOp {
title: String,
label: String,
initial: String,
submit: String,
on_submit: Callback<String>,
/// Runs the operation for the submitted (trimmed, non-empty) name.
/// Returns the localized error message on failure.
run: Callback<String, PromptOpFuture>,
/// Runs after a successful operation (toast + refresh); the dialog
/// closes regardless.
on_ok: Callback<()>,
},
/// Simple confirmation (delete).
Confirm {
@@ -95,6 +102,9 @@ pub enum Dialog {
},
}
/// The future returned by [`Dialog::PromptOp`]'s `run` callback.
pub type PromptOpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
/// Split a "/"-separated directory path into its non-empty segments.
fn dir_segs(dir: &str) -> Vec<String> {
dir.split('/')
@@ -130,28 +140,31 @@ pub fn DialogView(
};
let close = Callback::new(move |_| set_dialog.set(None));
match &d {
Dialog::Prompt {
Dialog::PromptOp {
title,
label,
initial,
submit,
on_submit,
run,
on_ok,
} => {
let (title, label, initial, submit, on_submit, close) = (
let (title, label, initial, submit, run, on_ok, close) = (
title.clone(),
label.clone(),
initial.clone(),
submit.clone(),
*on_submit,
*run,
*on_ok,
close,
);
view! {
<PromptDialog
<PromptOpDialog
title=title
label=label
initial=initial
submit=submit
on_submit=on_submit
run=run
on_ok=on_ok
on_close=close
/>
}
@@ -314,22 +327,45 @@ pub fn DialogView(
}
// ---------------------------------------------------------------------------
// Prompt (text input)
// Prompt (text input running an async operation)
// ---------------------------------------------------------------------------
#[component]
fn PromptDialog(
fn PromptOpDialog(
title: String,
label: String,
initial: String,
submit: String,
on_submit: Callback<String>,
run: Callback<String, PromptOpFuture>,
on_ok: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
let input_id = "fbng-prompt-input";
let id = input_id.to_string();
let id2 = id.clone();
let submit_cb = on_submit;
let input_id = "fbng-prompt-op-input";
let (error, set_error) = signal(Option::<String>::None);
let (busy, set_busy) = signal(false);
// The closure captures only Copy values, so the Enter-key and button
// handlers can share it directly.
let submit_fn = move || {
let name = api::input_value(input_id).trim().to_string();
if name.is_empty() || busy.get() {
return;
}
set_busy.set(true);
set_error.set(None);
let fut = run.run(name);
spawn_local(async move {
match fut.await {
Ok(()) => {
on_ok.run(());
on_close.run(());
}
Err(msg) => {
set_error.set(Some(msg));
set_busy.set(false);
}
}
});
};
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
@@ -340,20 +376,30 @@ fn PromptDialog(
class="input"
value=initial
autofocus=true
on:input=move |_| set_error.set(None)
on:keydown=move |e: web_sys::KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
submit_cb.run(api::input_value(&id));
submit_fn();
}
}
/>
{move || {
let Some(err) = error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{err}</p> }
.into_view()
.into_any()
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(i18n::k::CANCEL)}
</button>
<button
class="btn btn-primary"
on:click=move |_| submit_cb.run(api::input_value(&id2))
disabled=move || busy.get()
on:click=move |_| submit_fn()
>
{submit}
</button>
Mweb/src/views/shell.rs
@@ -7,7 +7,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Entry, Me};
use crate::components::icon::Icon;
use crate::components::logo::Logo;
use crate::components::toast::{ToastMsg, ToastView, provide_toast, show};
use crate::components::toast::{ToastMsg, ToastView, provide_toast, show_error};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate};
use crate::views::admin::{SettingsView, UsersView};
@@ -30,7 +30,7 @@ pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
spawn_local(async move {
match api::me().await {
Ok(m) => set_me.set(Some(m)),
Err(e) => show(toast, e.to_string()),
Err(e) => show_error(toast, e.to_string()),
}
});
}