UI: multi-select, two-pane folder picker, search start folder
- Browser: selection is a list. Ctrl/Cmd+click toggles, Shift+click selects a range from the anchor. Several entries get Move/Copy/Delete; conflicts are asked about once and the retry re-runs only the colliding items. - Folder picker rebuilt: roots on the left, breadcrumbs + subfolders on the right, fixed height, loading/empty states. Generalized (title, confirm, start folder) so search can reuse it. - Search starts in a chosen folder instead of a whole root. New `path` query param on the server, validated inside the root; result paths stay root-relative. - Context menu on empty space: "Search in this folder". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mapi-types/src/lib.rs
@@ -52,6 +52,9 @@ pub const P_Q: &str = "q";
pub const P_SCOPE: &str = "scope";
/// Root id to search; omitted = the caller's first root.
pub const P_ROOT: &str = "root";
/// Folder inside the root to start a search in (relative to the root);
/// omitted or empty = the whole root.
pub const P_PATH: &str = "path";
/// `?overwrite=true|1` on mutations and uploads.
pub const P_OVERWRITE: &str = "overwrite";
Mserver/src/api/search.rs
@@ -64,6 +64,9 @@ pub(super) struct SearchQuery {
/// The root to search; omitted = the caller's first root.
#[serde(default)]
root: Option<i64>,
/// Folder inside the root to start in; omitted = the whole root.
#[serde(default)]
path: Option<String>,
}
pub(super) async fn search(
@@ -118,11 +121,23 @@ pub(super) async fn search(
)
})?;
// The start folder must exist inside the root. Only validated here: the
// walk joins the un-canonicalized paths itself, so every result keeps
// the root's prefix and stays root-relative.
let start_rel = query
.path
.as_deref()
.unwrap_or("")
.trim_matches('/')
.to_string();
crate::fs::resolve_dir(&state.root, &root.path, &start_rel)?;
let events = search_stream(
q.to_string(),
want_name,
want_content,
root,
start_rel,
state.root.clone(),
);
// `Sse` does the `data: <json>\n\n` framing and the content-type and
@@ -138,6 +153,8 @@ struct SearchState {
tx: tokio::sync::mpsc::Sender<SearchEvent>,
words: Vec<String>,
root: RootRow,
/// Where the walk starts, relative to the root ("" = the root itself).
start_rel: String,
server_root: PathBuf,
started: Instant,
/// Directory entries visited.
@@ -161,6 +178,7 @@ fn search_stream(
want_name: bool,
want_content: bool,
root: RootRow,
start_rel: String,
server_root: PathBuf,
) -> impl futures_util::Stream<Item = SearchEvent> + Send {
let (tx, rx) = tokio::sync::mpsc::channel::<SearchEvent>(256);
@@ -168,6 +186,7 @@ fn search_stream(
tx,
words: q.split_whitespace().map(|w| w.to_lowercase()).collect(),
root,
start_rel,
server_root,
started: Instant::now(),
scanned: AtomicUsize::new(0),
@@ -207,11 +226,12 @@ fn name_matches(words: &[String], name: &str) -> bool {
/// [`WalkState::Quit`] to stop the whole walk (client went away).
fn walk(st: &Arc<SearchState>, q: &str, want_name: bool, want_content: bool) {
let abs = st.server_root.join(&st.root.path);
if !abs.is_dir() {
// Root removed out from under us: nothing to search.
let start = abs.join(&st.start_rel);
if !start.is_dir() {
// Root or start folder removed out from under us: nothing to search.
return;
}
WalkBuilder::new(&abs)
WalkBuilder::new(&start)
.standard_filters(true) // fd's defaults: hidden files + gitignore
.build_parallel()
.run(|| {
@@ -236,13 +256,13 @@ fn visit(
return WalkState::Quit;
}
st.scanned.fetch_add(1, Ordering::Relaxed);
// The root itself has an empty relative path and is not a result.
let Some(rel) = entry
.path()
.strip_prefix(abs)
.ok()
.filter(|p| !p.as_os_str().is_empty())
else {
// The start folder itself (depth 0) is not a result. Paths stay
// relative to the root, not to the start folder, so the client opens
// them the same way as any listing entry.
if entry.depth() == 0 {
return WalkState::Continue;
}
let Some(rel) = entry.path().strip_prefix(abs).ok() else {
return WalkState::Continue;
};
// Original case, unlike the name used for matching: this path is what
Mserver/tests/api_search.rs
@@ -55,6 +55,50 @@ async fn both_scopes_stream_from_one_walk() {
assert!(done["scanned"].as_u64().unwrap() >= 8);
}
#[tokio::test]
async fn path_narrows_the_walk_to_a_subfolder() {
let env = Env::new().await;
let admin = env.admin().await;
let paths = |body: String| -> Vec<String> {
events(&body)
.iter()
.filter(|e| e["type"] == "file")
.map(|e| e["path"].as_str().unwrap().to_string())
.collect()
};
// Inside `docs`: the hit is found and its path stays root-relative.
let r = admin
.get(&format!(
"/api/search?q=hello&scope=name&root={ROOT}&path=docs"
))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(paths(r.text()), vec!["docs/inner/hello.txt".to_string()]);
// The start folder itself is not a result.
let r = admin
.get(&format!(
"/api/search?q=docs&scope=name&root={ROOT}&path=docs"
))
.await;
assert!(paths(r.text()).is_empty());
// Outside `docs`: nothing.
let r = admin
.get(&format!(
"/api/search?q=hello&scope=name&root={ROOT}&path=src"
))
.await;
assert!(paths(r.text()).is_empty());
// A missing or escaping start folder is rejected.
let r = admin
.get(&format!("/api/search?q=hello&root={ROOT}&path=nope"))
.await;
assert!(r.status.is_client_error());
let r = admin
.get(&format!("/api/search?q=hello&root={ROOT}&path=../"))
.await;
assert!(r.status.is_client_error());
}
#[tokio::test]
async fn name_scope_ignores_the_parent_path() {
let env = Env::new().await;
Mweb/app.css
@@ -891,7 +891,7 @@ button:disabled {
}
.modal.picker {
width: 520px;
width: 640px;
}
.modal-title {
@@ -960,36 +960,87 @@ button:disabled {
font-size: 13px;
}
/* Folder picker: roots on the left, the selected root's tree on the right. */
.picker-body {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
border: 1px solid var(--border);
height: 320px;
}
.picker-roots {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
flex-direction: column;
gap: 2px;
padding: 6px;
border-right: 1px solid var(--border);
background: var(--bg);
min-height: 0;
overflow-y: auto;
}
.root-opt {
.picker-root {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
min-width: 0;
}
.picker-root:hover {
background: var(--panel);
}
.root-opt.active {
.picker-root.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.picker-current {
font-size: 13px;
margin: 0 0 8px;
.picker-root.ro .picker-root-name {
opacity: 0.6;
text-decoration: line-through dotted;
}
.picker-root-ic {
width: 16px;
height: 16px;
flex: none;
}
.picker-root-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-main {
display: flex;
flex-direction: column;
min-width: 0;
/* Grid items default to min-height:auto, which would let a long
folder list stretch the dialog instead of scrolling. */
min-height: 0;
}
.picker-crumbs {
margin-bottom: 8px;
padding: 8px 10px;
font-size: 13px;
border-bottom: 1px solid var(--border);
flex: none;
}
.picker-entries {
max-height: 240px;
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
}
.picker-row {
@@ -998,12 +1049,63 @@ button:disabled {
gap: 10px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
user-select: none;
}
.picker-row:hover {
background: var(--bg);
}
.picker-row .row-name {
flex: 1;
}
/* A right-pointing chevron drawn with borders (no icon needed). */
.picker-chev {
width: 7px;
height: 7px;
border-right: 1.5px solid var(--muted);
border-bottom: 1.5px solid var(--muted);
transform: rotate(-45deg);
flex: none;
}
.picker-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
}
.picker-empty-ic {
width: 32px;
height: 32px;
opacity: 0.5;
}
.picker-root.active .picker-root-ic {
color: #fff;
}
@media (max-width: 640px) {
.picker-body {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
height: 380px;
}
.picker-roots {
flex-direction: row;
flex-wrap: wrap;
border-right: none;
border-bottom: 1px solid var(--border);
}
}
/* ---------------------------------------------------------------------------
* Full-page file view (preview / editor / "no preview"). The top bar carries
* the title + actions; the content area below is edge to edge.
@@ -1685,18 +1787,39 @@ button:disabled {
color: #fff;
}
/* Roots multi-select --------------------------------------------- */
/* Start folder --------------------------------------------------- */
/* The root a search covers. A native select, styled like the query bar. */
.root-select {
/* The folder a search starts in; opens the folder picker. */
.folder-pick {
flex: none;
max-width: 220px;
display: flex;
align-items: center;
gap: 8px;
max-width: 260px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: inherit;
font-size: 13px;
padding: 0 10px;
cursor: pointer;
}
.folder-pick:hover {
border-color: var(--muted);
}
.folder-pick .fp-icon {
width: 16px;
height: 16px;
color: var(--muted);
flex: none;
}
.folder-pick .fp-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms {
Mweb/src/api.rs
@@ -15,7 +15,7 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
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_Q, P_ROOT,
CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT,
P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARES, Settings, UpdateUser,
};
pub use api_types::{
@@ -770,12 +770,15 @@ pub fn search_stream(
q: String,
scope: &str,
root: i64,
// `path`: folder inside the root to start in ("" = the whole root).
path: &str,
on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
on_error: leptos::prelude::Callback<String, ()>,
) -> Result<web_sys::EventSource, ApiError> {
let url = format!(
"{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}",
"{SEARCH}?{P_Q}={}&{P_SCOPE}={scope}&{P_ROOT}={root}&{P_PATH}={}",
js_sys::encode_uri_component(&q),
js_sys::encode_uri_component(path),
);
let src = web_sys::EventSource::new(&url).map_err(|e| ApiError::Net(format!("{e:?}")))?;
Mweb/src/i18n.rs
@@ -201,6 +201,7 @@ pub mod k {
pub const NEW_NAME: &str = "new_name";
pub const DELETE_FOLDER_MSG: &str = "delete_folder_msg";
pub const DELETE_MSG: &str = "delete_msg";
pub const DELETE_N_MSG: &str = "delete_n_msg";
pub const CANCEL: &str = "cancel";
// Dialogs: conflict / picker / download format / info
@@ -210,10 +211,10 @@ pub mod k {
pub const NO_SUBFOLDERS: &str = "no_subfolders";
pub const MOVE_TO: &str = "move_to";
pub const COPY_TO: &str = "copy_to";
pub const READ_ONLY_ROOT: &str = "read_only_root";
pub const MOVE_N_TO: &str = "move_n_to";
pub const COPY_N_TO: &str = "copy_n_to";
pub const MOVE_HERE: &str = "move_here";
pub const COPY_HERE: &str = "copy_here";
pub const TOP: &str = "top";
pub const DOWNLOAD_TITLE: &str = "download_title";
pub const DOWNLOAD_FORMATS_MSG: &str = "download_formats_msg";
pub const FMT_ZIP: &str = "fmt_zip";
@@ -268,6 +269,9 @@ pub mod k {
pub const SEARCH_BOTH: &str = "search_both";
pub const SEARCH_GOTO: &str = "search_goto";
pub const SEARCH_RUN: &str = "search_run";
pub const SEARCH_IN: &str = "search_in";
pub const SEARCH_HERE: &str = "search_here";
pub const SEARCH_IN_FOLDER: &str = "search_in_folder";
pub const SEARCH_STOP: &str = "search_stop";
pub const SEARCHING: &str = "searching";
pub const SEARCH_N_RESULTS: &str = "search_n_results";
@@ -512,6 +516,7 @@ const EN: &[(&str, &str)] = &[
"Delete folder “{}” and everything inside it?\nThis cannot be undone.",
),
("delete_msg", "Delete “{}”?\nThis cannot be undone."),
("delete_n_msg", "Delete {} items?\nThis cannot be undone."),
("cancel", "Cancel"),
(
"overwrite_question",
@@ -519,16 +524,13 @@ const EN: &[(&str, &str)] = &[
),
("overwrite", "Overwrite"),
("and_more", "…and {} more"),
(
"no_subfolders",
"No subfolders — the file will be placed in the current folder.",
),
("no_subfolders", "No subfolders"),
("move_to", "Move “{}” to…"),
("copy_to", "Copy “{}” to…"),
("read_only_root", "{} (read-only)"),
("move_n_to", "Move {} items to…"),
("copy_n_to", "Copy {} items to…"),
("move_here", "Move here"),
("copy_here", "Copy here"),
("top", "top"),
("download_title", "Download “{}”"),
(
"download_formats_msg",
@@ -580,6 +582,9 @@ const EN: &[(&str, &str)] = &[
("search_both", "Both"),
("search_goto", "Show in folder"),
("search_run", "Search"),
("search_in", "Search in…"),
("search_here", "Search here"),
("search_in_folder", "Search in this folder"),
("search_stop", "Stop"),
("searching", "searching…"),
("search_n_results", "{} results"),
@@ -892,6 +897,10 @@ const DE: &[(&str, &str)] = &[
"delete_msg",
"„{}“ löschen?\nDas kann nicht rückgängig gemacht werden.",
),
(
"delete_n_msg",
"{} Elemente löschen?\nDas kann nicht rückgängig gemacht werden.",
),
("cancel", "Abbrechen"),
(
"overwrite_question",
@@ -899,16 +908,13 @@ const DE: &[(&str, &str)] = &[
),
("overwrite", "Überschreiben"),
("and_more", "…und {} weitere"),
(
"no_subfolders",
"Keine Unterordner – die Datei wird im aktuellen Ordner abgelegt.",
),
("no_subfolders", "Keine Unterordner"),
("move_to", "„{}“ verschieben nach…"),
("copy_to", "„{}“ kopieren nach…"),
("read_only_root", "{} (schreibgeschützt)"),
("move_n_to", "{} Elemente verschieben nach…"),
("copy_n_to", "{} Elemente kopieren nach…"),
("move_here", "Hierher verschieben"),
("copy_here", "Hierher kopieren"),
("top", "Wurzel"),
("download_title", "„{}“ herunterladen"),
(
"download_formats_msg",
@@ -963,6 +969,9 @@ const DE: &[(&str, &str)] = &[
("search_both", "Beides"),
("search_goto", "Im Ordner anzeigen"),
("search_run", "Suchen"),
("search_in", "Suchen in…"),
("search_here", "Hier suchen"),
("search_in_folder", "In diesem Ordner suchen"),
("search_stop", "Stopp"),
("searching", "Suche läuft…"),
("search_n_results", "{} Treffer"),
@@ -1338,6 +1347,10 @@ const FR: &[(&str, &str)] = &[
"delete_msg",
"Supprimer « {} » ?\nCette action est irréversible.",
),
(
"delete_n_msg",
"Supprimer {} éléments ?\nCette action est irréversible.",
),
("cancel", "Annuler"),
(
"overwrite_question",
@@ -1345,16 +1358,13 @@ const FR: &[(&str, &str)] = &[
),
("overwrite", "Écraser"),
("and_more", "…et {} autres"),
(
"no_subfolders",
"Aucun sous-dossier – le fichier sera placé dans le dossier actuel.",
),
("no_subfolders", "Aucun sous-dossier"),
("move_to", "Déplacer « {} » vers…"),
("copy_to", "Copier « {} » vers…"),
("read_only_root", "{} (lecture seule)"),
("move_n_to", "Déplacer {} éléments vers…"),
("copy_n_to", "Copier {} éléments vers…"),
("move_here", "Déplacer ici"),
("copy_here", "Copier ici"),
("top", "racine"),
("download_title", "Télécharger « {} »"),
(
"download_formats_msg",
@@ -1409,6 +1419,9 @@ const FR: &[(&str, &str)] = &[
("search_both", "Les deux"),
("search_goto", "Afficher dans le dossier"),
("search_run", "Rechercher"),
("search_in", "Rechercher dans…"),
("search_here", "Rechercher ici"),
("search_in_folder", "Rechercher dans ce dossier"),
("search_stop", "Arrêter"),
("searching", "recherche…"),
("search_n_results", "{} résultats"),
Mweb/src/views/browser.rs
@@ -1,5 +1,7 @@
//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
use std::sync::{Arc, Mutex};
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
@@ -40,13 +42,9 @@ pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a R
}
}
#[derive(Clone)]
struct CtxMenu {
x: i32,
y: i32,
/// None = empty area / root tile (no entry selected).
entry: Option<Entry>,
}
/// Where the context menu opens (client x, y). What it offers follows the
/// selection: the right-click handlers set that first.
type CtxMenu = (i32, i32);
#[component]
pub fn Browser(
@@ -58,10 +56,11 @@ pub fn Browser(
/// The browser exposes its fetch there so the full-page editor can
/// re-list the folder after a save.
set_browser_refresh: WriteSignal<Option<Callback<()>>>,
/// The single selected entry, owned by the shell so its action buttons
/// can live in the top bar. `None` shows the empty-area actions there.
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
/// The selected entries, owned by the shell so their action buttons can
/// live in the top bar. Empty shows the empty-area actions there. The
/// first entry is the anchor for Shift+click ranges.
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
/// Dialogs are rendered at the shell level.
set_dialog: WriteSignal<Option<Dialog>>,
/// The open dialog, if any. Escape must not clear the selection while
@@ -87,14 +86,19 @@ pub fn Browser(
// /me comes back unchanged.
let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
// Drop the selection when the entry disappears from the listing
// (deleted, renamed, …).
// Drop selected entries that disappeared from the listing (deleted,
// renamed, …).
Effect::new(move |_| {
if let ListState::Entries(entries) = list_state.get()
&& let Some(s) = selected.get()
&& !entries.iter().any(|e| e.name == s.name)
{
set_selected.set(None);
if let ListState::Entries(entries) = list_state.get() {
let cur = selected.get_untracked();
let kept: Vec<Entry> = cur
.iter()
.filter(|s| entries.iter().any(|e| e.name == s.name))
.cloned()
.collect();
if kept.len() != cur.len() {
set_selected.set(kept);
}
}
});
// Escape clears the selection — but only when nothing that also reacts
@@ -122,7 +126,7 @@ pub fn Browser(
{
let _ = active.blur();
}
set_selected.set(None);
set_selected.set(Vec::new());
},
);
@@ -205,6 +209,7 @@ pub fn Browser(
set_ctx=set_ctx
me=me
loc=loc
selected=selected
refresh=fetch
toast=toast
set_dialog=set_dialog
@@ -261,8 +266,8 @@ fn file_browser(
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
fetch: Callback<()>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
@@ -376,15 +381,11 @@ fn file_browser(
<div
class="entries-area"
// Clicking empty space clears the selection.
on:click=move |_| set_selected.set(None)
on:click=move |_| set_selected.set(Vec::new())
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_selected.set(None);
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
entry: None,
}));
set_selected.set(Vec::new());
set_ctx.set(Some((ev.client_x(), ev.client_y())));
}
>
{move || {
@@ -449,50 +450,91 @@ fn file_browser(
}
}
/// Right-click on an entry: select it and open the context menu on it.
/// Right-click on an entry: open the context menu on the selection. An
/// entry that is already selected keeps the whole selection (multi-item
/// menu); any other entry becomes the only selected one.
fn ctx_menu_handler(
entry: Entry,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_selected: WriteSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
set_selected.set(Some(entry.clone()));
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
entry: Some(entry.clone()),
}));
if !selected
.get_untracked()
.iter()
.any(|s| s.name == entry.name)
{
set_selected.set(vec![entry.clone()]);
}
set_ctx.set(Some((ev.client_x(), ev.client_y())));
}
}
/// Left-click behaviour per the "single click opens" profile setting:
/// - off (default): the click selects the entry; a double click opens it;
/// - on: the click opens it, Ctrl/Cmd+click toggles the selection.
/// Left-click behaviour. Modifier clicks always select:
/// - Ctrl/Cmd+click toggles the entry;
/// - Shift+click selects the range from the anchor (the first selected
/// entry) to the clicked one, in listing order; with Ctrl it adds the
/// range instead of replacing the selection.
///
/// A plain click follows the "single click opens" profile setting: off
/// (default) selects the entry alone (a double click opens it); on opens it.
///
/// Selecting clicks stop propagation so the empty-area click (deselect) and
/// the double-click handler don't interfere.
fn selection_click(
entry: Entry,
all: Arc<Vec<Entry>>,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
open: Callback<()>,
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
if single_click {
if ev.ctrl_key() || ev.meta_key() {
ev.stop_propagation();
let is_sel = selected.get().is_some_and(|s| s.name == entry.name);
set_selected.set(if is_sel { None } else { Some(entry.clone()) });
let ctrl = ev.ctrl_key() || ev.meta_key();
if !ctrl && !ev.shift_key() {
if single_click {
open.run(());
return;
}
open.run(());
ev.stop_propagation();
set_selected.set(vec![entry.clone()]);
return;
}
ev.stop_propagation();
set_selected.set(Some(entry.clone()));
let cur = selected.get_untracked();
if ev.shift_key() {
let pos = |n: &str| all.iter().position(|e| e.name == n);
let anchor = cur
.first()
.map(|a| a.name.clone())
.unwrap_or(entry.name.clone());
let (Some(a), Some(b)) = (pos(&anchor), pos(&entry.name)) else {
return;
};
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
// The anchor stays first so the next Shift+click extends from it.
let mut next: Vec<Entry> = if ctrl { cur } else { Vec::new() };
let mut add = |e: &Entry| {
if !next.iter().any(|s| s.name == e.name) {
next.push(e.clone());
}
};
add(&all[a]);
all[lo..=hi].iter().for_each(&mut add);
set_selected.set(next);
return;
}
let mut next = cur;
match next.iter().position(|s| s.name == entry.name) {
Some(i) => {
next.remove(i);
}
None => next.push(entry.clone()),
}
set_selected.set(next);
}
}
@@ -556,12 +598,13 @@ fn open_entry(
#[allow(clippy::too_many_arguments)] // explicit signal props
fn entry_callbacks(
entry: &Entry,
all: Arc<Vec<Entry>>,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> (
@@ -572,11 +615,24 @@ fn entry_callbacks(
let open_cb = open_entry(entry, root_id, loc, is_rw, open_file);
(
open_cb,
selection_click(entry.clone(), single_click, selected, set_selected, open_cb),
ctx_menu_handler(entry.clone(), set_ctx, set_selected),
selection_click(
entry.clone(),
all,
single_click,
selected,
set_selected,
open_cb,
),
ctx_menu_handler(entry.clone(), set_ctx, selected, set_selected),
)
}
/// Whether a double click opens: not with a modifier held, because that is
/// two selecting clicks (the browser still fires `dblclick` for them).
fn plain_dblclick(ev: &MouseEvent) -> bool {
!(ev.ctrl_key() || ev.meta_key() || ev.shift_key())
}
#[allow(clippy::too_many_arguments)] // explicit signal props
fn grid_view(
entries: &[Entry],
@@ -584,11 +640,13 @@ fn grid_view(
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
// Shared by every entry's click handler, for Shift+click ranges.
let all = Arc::new(entries.to_vec());
view! {
<div class="entries-grid">
{entries.iter().map(|e| {
@@ -596,17 +654,17 @@ fn grid_view(
let name = e.name.clone();
let title = name.clone();
let (open_cb, on_click, on_ctx) =
entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
entry_callbacks(e, all.clone(), root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
let sel_check = selected;
let tile_name = name.clone();
let selected_now = move || sel_check.get().is_some_and(|s| s.name == tile_name);
let selected_now = move || sel_check.with(|v| v.iter().any(|s| s.name == tile_name));
view! {
<div
class="tile"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |_| open_cb.run(())
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
@@ -632,11 +690,13 @@ fn list_view(
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
// Shared by every entry's click handler, for Shift+click ranges.
let all = Arc::new(entries.to_vec());
view! {
<div class="entries-list">
{entries.iter().map(|e| {
@@ -647,17 +707,17 @@ fn list_view(
let is_dir = e.is_dir;
let title = name.clone();
let (open_cb, on_click, on_ctx) =
entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
entry_callbacks(e, all.clone(), root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
let sel_check = selected;
let row_name = name.clone();
let selected_now = move || sel_check.get().is_some_and(|s| s.name == row_name);
let selected_now = move || sel_check.with(|v| v.iter().any(|s| s.name == row_name));
view! {
<div
class="row"
class:selected=selected_now
tabindex=0
on:click=on_click
on:dblclick=move |_| open_cb.run(())
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
@@ -682,12 +742,13 @@ fn list_view(
// Context menu
// ---------------------------------------------------------------------------
/// Build the action list for an entry (or the empty area, when `entry` is
/// `None`). Shared by the right-click context menu and the toolbar's
/// selection actions so both always offer the same operations.
/// Build the action list for the selection: the empty area (nothing
/// selected), one entry, or several. Shared by the right-click context menu
/// and the toolbar's selection actions so both always offer the same
/// operations.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn menu_items(
entry: Option<&Entry>,
sel: &[Entry],
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
open_file: WriteSignal<Option<FileView>>,
@@ -711,51 +772,98 @@ fn menu_items(
.map(|m| m.allow_writable_shares)
.unwrap_or(false);
match entry {
Some(e) => {
let mut v = vec![];
// Exactly what left-clicking the entry does: descend into a
// folder, open a text file in the editor (read-only in read-only
// folders), or preview media.
if let Some(rid) = root_id {
match sel {
[] => {
let mut v = vec![
MenuItem::rw(
IconName::NewFolder,
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
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,
i18n::t(i18n::k::UPLOAD_FILES),
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
IconName::UploadFolder,
i18n::t(i18n::k::UPLOAD_FOLDER),
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
];
// Search is a signed-in feature; a share visitor has none.
if let Some(rid) = root_id
&& loc_now.share_token.is_none()
{
let dir = loc_now.path.join("/");
v.push(MenuItem::new(
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
i18n::t(i18n::k::OPEN),
open_entry(e, rid, loc, is_rw, open_file),
IconName::Search,
i18n::t(i18n::k::SEARCH_IN_FOLDER),
owner.with(|| {
Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
}),
));
}
if !e.is_dir {
// Serve the file itself in a new tab. HTML and SVG render as
// real pages there (sandboxed by the server), which is how
// you share a small site or an HTML report.
v
}
// One entry gets every action; several get only the ones that take
// a set (move, copy, delete).
_ => {
let one = if let [e] = sel { Some(e) } else { None };
let mut v = vec![];
if let Some(e) = one {
// Exactly what left-clicking the entry does: descend into a
// folder, open a text file in the editor (read-only in
// read-only folders), or preview media.
if let Some(rid) = root_id {
v.push(MenuItem::new(
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
i18n::t(i18n::k::OPEN),
open_entry(e, rid, loc, is_rw, open_file),
));
}
if !e.is_dir {
// Serve the file itself in a new tab. HTML and SVG render
// as real pages there (sandboxed by the server), which is
// how you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
i18n::t(i18n::k::OPEN_IN_NEW_TAB),
action_open_tab(e, root_id, loc, owner.clone()),
));
}
v.push(MenuItem::new(
IconName::OpenInNew,
i18n::t(i18n::k::OPEN_IN_NEW_TAB),
action_open_tab(e, root_id, loc, owner.clone()),
IconName::Download,
i18n::t(i18n::k::DOWNLOAD),
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, owner.clone()),
));
}
v.push(MenuItem::new(
IconName::Download,
i18n::t(i18n::k::DOWNLOAD),
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, owner.clone()),
));
v.push(MenuItem::rw(
IconName::Move,
i18n::t(i18n::k::MOVE),
is_rw,
action_move_copy(
Op::Move,
e,
sel,
root_id,
me,
loc,
@@ -772,7 +880,7 @@ fn menu_items(
i18n::t(i18n::k::COPY),
action_move_copy(
Op::Copy,
e,
sel,
root_id,
me,
loc,
@@ -782,60 +890,36 @@ fn menu_items(
owner.clone(),
),
));
if loc_now.share_token.is_none() {
if let Some(e) = one {
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
i18n::t(i18n::k::SHARE),
action_share(
e,
root_id,
loc,
allow_writable_shares,
is_rw,
set_dialog,
owner.clone(),
),
));
}
v.push(MenuItem::new(
IconName::Share,
i18n::t(i18n::k::SHARE),
action_share(
e,
root_id,
loc,
allow_writable_shares,
is_rw,
set_dialog,
owner.clone(),
),
IconName::Info,
i18n::t(i18n::k::INFO),
action_info(e, loc, root_name, set_dialog, owner.clone()),
));
}
v.push(MenuItem::new(
IconName::Info,
i18n::t(i18n::k::INFO),
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, owner.clone()),
action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
));
v
}
None => vec![
MenuItem::rw(
IconName::NewFolder,
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
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,
i18n::t(i18n::k::UPLOAD_FILES),
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
IconName::UploadFolder,
i18n::t(i18n::k::UPLOAD_FOLDER),
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
],
}
}
@@ -845,6 +929,7 @@ fn CtxMenuView(
set_ctx: WriteSignal<Option<CtxMenu>>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
selected: ReadSignal<Vec<Entry>>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
@@ -872,7 +957,7 @@ fn CtxMenuView(
// viewport edges as a fallback.
let menu_ref = NodeRef::<leptos::html::Div>::new();
Effect::new(move |_| {
let Some(m) = ctx.get() else {
let Some((mx, my)) = ctx.get() else {
return;
};
let Some(node) = menu_ref
@@ -891,21 +976,21 @@ fn CtxMenuView(
})
.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 x = if mx + w > vw { mx - w } else { mx }.clamp(0, (vw - w).max(0));
let y = if my + h > vh { my - h } else { my }.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 {
let Some((mx, my)) = ctx.get() else {
return {
view! {}.into_any()
};
};
let items = menu_items(
m.entry.as_ref(),
&selected.get(),
me,
loc,
open_file,
@@ -919,7 +1004,7 @@ fn CtxMenuView(
<div
class="ctx-menu"
node_ref=menu_ref
style=format!("left:{}px;top:{}px", m.x, m.y)
style=format!("left:{mx}px;top:{my}px")
>
{items.into_iter().map(|item| {
view! { <CtxItem item=item/> }
@@ -993,15 +1078,15 @@ fn CtxItem(item: MenuItem) -> impl IntoView {
// Toolbar selection actions (top right)
// ---------------------------------------------------------------------------
/// Icon-only buttons for the currently selected entry — the same set of
/// actions as its right-click menu — or the empty-area actions (new folder,
/// uploads) when nothing is selected. Rendered in the app's top bar (top
/// Icon-only buttons for the current selection — the same set of actions
/// as its right-click menu — or the empty-area actions (new folder,
/// uploads, search) when nothing is selected. Rendered in the app's top bar (top
/// right). Buttons that don't fit collapse into a three-dot overflow menu.
#[component]
pub fn SelectionActions(
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
selected: ReadSignal<Option<Entry>>,
selected: ReadSignal<Vec<Entry>>,
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
@@ -1093,7 +1178,7 @@ pub fn SelectionActions(
};
}
let items = menu_items(
selected.get().as_ref(),
&selected.get(),
me,
loc,
open_file,
@@ -1380,8 +1465,10 @@ fn action_rename(
})
}
/// Delete the entries after one confirmation. Several are deleted one by
/// one; the first failure stops and reports.
fn action_delete(
entry: &Entry,
entries: &[Entry],
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
@@ -1389,18 +1476,16 @@ fn action_delete(
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
let is_dir = entry.is_dir;
let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.clone();
let full = join_path(&dir, &name);
let message = if is_dir {
i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, &name)
} else {
i18n::t_fmt(i18n::k::DELETE_MSG, &name)
let dir = loc.get().path;
let message = match items.as_slice() {
[(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
[(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
};
let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
set_dialog.set(Some(Dialog::Confirm {
title: i18n::t(i18n::k::DELETE).to_string(),
message,
@@ -1409,15 +1494,17 @@ fn action_delete(
on_submit: owner.with(|| {
Callback::new(move |_| {
set_dialog.set(None);
let f = full.clone();
let fulls = fulls.clone();
spawn_local(async move {
match api::delete_item(root_id, &f).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::DELETED));
for f in &fulls {
if let Err(e) = api::delete_item(root_id, f).await {
show_error(toast, e.to_string());
refresh.run(());
return;
}
Err(e) => show_error(toast, e.to_string()),
}
show(toast, i18n::t(i18n::k::DELETED));
refresh.run(());
});
})
}),
@@ -1460,10 +1547,13 @@ fn action_upload(
})
}
/// Move or copy the entries to a folder chosen in the picker. Several are
/// processed one by one; name collisions are collected and asked about once,
/// and the overwrite retry re-runs only the items that collided.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_move_copy(
op: Op,
entry: &Entry,
entries: &[Entry],
root_id: Option<i64>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
@@ -1472,45 +1562,74 @@ fn action_move_copy(
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
let entry2 = entry.clone();
let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
Callback::new(move |_| {
let loc = loc.get();
let me = me.get();
let Some(root_id) = root_id else { return };
let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
let roots = me.get().map(|m| m.roots).unwrap_or_default();
let dir = loc.path.clone();
let full = join_path(&dir, &name);
let dir_str = loc.path.join("/");
let name3 = name.clone();
let entry3 = entry2.clone();
let full3 = full.clone();
let title = match names.as_slice() {
[one] => i18n::t_fmt(op.title_key(1), one),
many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
};
// (name, path relative to the root) per item.
let items: Vec<(String, String)> = names
.iter()
.map(|n| (n.clone(), join_path(&dir, n)))
.collect();
set_dialog.set(Some(Dialog::Picker {
op,
entry: entry3,
title,
confirm: i18n::t(op.here_key()).to_string(),
roots,
source_root: root_id,
source_dir: dir_str,
root: root_id,
dir: dir.join("/"),
for_write: true,
on_pick: owner.with(|| {
Callback::new(move |(dst_root, dst_dir): (i64, String)| {
set_dialog.set(None);
let full = full3.clone();
// Items still to do. A finished item is dropped, so the
// overwrite retry re-runs only the ones that collided.
let pending = Arc::new(Mutex::new(items.clone()));
run_with_overwrite_retry(
move |overwrite| {
let (full, dst_dir) = (full.clone(), dst_dir.clone());
let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
Box::pin(async move {
let res = if op == Op::Move {
api::move_item(root_id, &full, dst_root, &dst_dir, overwrite)
let items = pending.lock().unwrap().clone();
let mut conflicts = Vec::new();
for (name, full) in items {
let res = if op == Op::Move {
api::move_item(
root_id, &full, dst_root, &dst_dir, overwrite,
)
.await
} else {
api::copy_item(root_id, &full, dst_root, &dst_dir, overwrite)
} else {
api::copy_item(
root_id, &full, dst_root, &dst_dir, overwrite,
)
.await
};
res.map(|_| ())
};
match res {
Ok(_) => {
pending.lock().unwrap().retain(|(_, p)| *p != full)
}
Err(e) if is_conflict(&e) => conflicts.push(name),
Err(e) => return Err(e),
}
}
if conflicts.is_empty() {
Ok(())
} else {
Err(api::ApiError::Http {
status: 409,
message: String::new(),
skipped: Some(conflicts),
})
}
})
},
i18n::t(i18n::k::NAME_TAKEN),
vec![name3.clone()],
// Unused: the conflict error above always lists the names.
Vec::new(),
if op == Op::Move {
i18n::t(i18n::k::MOVED)
} else {
Mweb/src/views/dialogs.rs
@@ -1,6 +1,8 @@
//! Modal dialogs: text prompts (new folder / rename), confirmations (delete),
//! overwrite conflict warnings, and the move/copy destination picker.
use std::sync::Arc;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
@@ -19,11 +21,15 @@ pub enum Op {
}
impl Op {
/// The translated title prefix for the destination picker ("Move … to…").
pub fn title_key(self) -> &'static str {
match self {
Op::Move => crate::i18n::k::MOVE_TO,
Op::Copy => crate::i18n::k::COPY_TO,
/// The destination picker's title for `n` items: "Move “{}” to…" for
/// one (the name goes in), "Move {} items to…" for more (the count).
pub fn title_key(self, n: usize) -> &'static str {
use crate::i18n::k;
match (self, n) {
(Op::Move, 1) => k::MOVE_TO,
(Op::Copy, 1) => k::COPY_TO,
(Op::Move, _) => k::MOVE_N_TO,
(Op::Copy, _) => k::COPY_N_TO,
}
}
@@ -68,13 +74,19 @@ pub enum Dialog {
files: Vec<String>,
on_submit: Callback<()>,
},
/// Move/copy destination picker.
/// Folder picker: the move/copy destination, or where a search starts.
Picker {
op: Op,
entry: Entry,
title: String,
/// The confirm button label ("Move here", "Search here").
confirm: String,
roots: Vec<RootInfo>,
source_root: i64,
source_dir: String,
/// The folder the picker opens in.
root: i64,
dir: String,
/// The picked folder is written to (move/copy destination): only a
/// writable root can be confirmed, and not the folder the picker
/// opened in (a move or copy onto itself is a no-op).
for_write: bool,
on_pick: Callback<(i64, String)>,
},
/// Folder download — format choice (zip | tar | tar.gz | tar.zst).
@@ -222,30 +234,23 @@ pub fn DialogView(
.into_any()
}
Dialog::Picker {
op,
entry,
title,
confirm,
roots,
source_root,
source_dir,
root,
dir,
for_write,
on_pick,
} => {
let (op, entry, roots, source_root, source_dir, on_pick, close) = (
*op,
entry.clone(),
roots.clone(),
*source_root,
source_dir.clone(),
*on_pick,
close,
);
view! {
<PickerDialog
op=op
entry=entry
roots=roots
source_root=source_root
source_dir=source_dir
on_pick=on_pick
title=title.clone()
confirm=confirm.clone()
roots=roots.clone()
root=*root
dir=dir.clone()
for_write=*for_write
on_pick=*on_pick
on_close=close
/>
}
@@ -507,174 +512,164 @@ fn ConflictDialog(
}
// ---------------------------------------------------------------------------
// Picker (move / copy destination)
// Picker (folder chooser)
// ---------------------------------------------------------------------------
/// Two panes: the roots on the left, the folder tree of the selected root
/// on the right (breadcrumbs + subfolders). Clicking a subfolder descends;
/// the breadcrumbs go back up and name the chosen folder.
#[component]
fn PickerDialog(
op: Op,
entry: Entry,
title: String,
confirm: String,
roots: Vec<RootInfo>,
source_root: i64,
source_dir: String,
root: i64,
dir: String,
for_write: bool,
on_pick: Callback<(i64, String)>,
on_close: Callback<()>,
) -> impl IntoView {
let (sel_root, set_sel_root) = signal(source_root);
let (sel_dir, set_sel_dir) = signal(source_dir.clone());
let (sel_root, set_sel_root) = signal(root);
let (sel_dir, set_sel_dir) = signal(dir.clone());
let (entries, set_entries) = signal(Vec::<Entry>::new());
let (loading, set_loading) = signal(true);
// Load the subfolders of the selected directory whenever it changes.
{
let root_sig = sel_root;
let dir_sig = sel_dir;
let set_ent = set_entries;
Effect::new(move |_| {
let rid = root_sig.get();
let dir = dir_sig.get();
set_ent.set(Vec::new());
spawn_local(async move {
match api::list_files(rid, &dir).await {
Ok(r) => set_ent.set(r.entries.into_iter().filter(|e| e.is_dir).collect()),
Err(_) => set_ent.set(Vec::new()),
}
});
Effect::new(move |_| {
let rid = sel_root.get();
let dir = sel_dir.get();
set_loading.set(true);
spawn_local(async move {
let subs = match api::list_files(rid, &dir).await {
Ok(r) => r.entries.into_iter().filter(|e| e.is_dir).collect(),
Err(_) => Vec::new(),
};
set_entries.set(subs);
set_loading.set(false);
});
}
});
let by_id = Arc::new(roots.clone());
let root_name = {
let by_id = by_id.clone();
move |rid: i64| {
by_id
.iter()
.find(|r| r.id == rid)
.map(|r| r.name.clone())
.unwrap_or_default()
}
};
let root_writable = move |rid: i64| by_id.iter().any(|r| r.id == rid && r.mode.is_writable());
let descend = move |name: String| {
let cur = sel_dir.get();
set_sel_dir.set(if cur.is_empty() {
name
} else {
format!("{cur}/{name}")
});
};
let sel_root2 = sel_root;
let sel_dir2 = sel_dir;
let set_dir = set_sel_dir;
let roots_by_id = roots.clone();
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">
{move || i18n::t_fmt(op.title_key(), &entry.name)}
</h2>
<h2 class="modal-title">{title}</h2>
<div class="picker-roots">
{roots.iter().map(|r| {
let rid = r.id;
let ro = !r.mode.is_writable();
let name = if ro {
i18n::t_fmt(i18n::k::READ_ONLY_ROOT, &r.name)
} else {
r.name.clone()
};
view! {
<button
class=move || {
if rid == sel_root2.get() {
"btn root-opt active".to_string()
} else {
"btn root-opt".to_string()
<div class="picker-body">
<div class="picker-roots">
{roots.iter().map(|r| {
let rid = r.id;
let ro = !r.mode.is_writable();
let name = r.name.clone();
view! {
<button
class="picker-root"
class:active=move || rid == sel_root.get()
class:ro=ro
title=move || if ro { i18n::t(i18n::k::READ_ONLY_HINT) } else { "" }
on:click=move |_| {
set_sel_root.set(rid);
set_sel_dir.set(String::new());
}
}
on:click=move |_| {
set_sel_root.set(rid);
set_sel_dir.set(String::new());
}
>
{name}
</button>
}
}).collect::<Vec<_>>()}
</div>
<p class="picker-current muted">
{move || {
let rid = sel_root2.get();
let rname = roots_by_id
.iter()
.find(|r| r.id == rid)
.map(|r| r.name.clone())
.unwrap_or_default();
let dir = sel_dir2.get();
if dir.is_empty() {
format!("{rname}/")
} else {
format!("{rname}/{dir}")
}
}}
</p>
>
<Icon name=IconName::Folder class="picker-root-ic".to_string()/>
<span class="picker-root-name">{name}</span>
</button>
}
}).collect::<Vec<_>>()}
</div>
// Breadcrumbs within the current directory.
<div class="crumbs picker-crumbs">
<span
class="crumb"
on:click=move |_| set_dir.set(String::new())
>
{i18n::tr(i18n::k::TOP)}
</span>
{move || {
let segs = dir_segs(&sel_dir2.get());
segs
.iter()
.enumerate()
.map(|(i, seg)| {
let seg = seg.clone();
let prefix: String = segs[..=i].join("/");
let is_last = i == segs.len() - 1;
view! {
<span class="crumb-sep">"/"</span>
<span
class=move || {
if is_last {
"crumb current".to_string()
} else {
"crumb".to_string()
}
<div class="picker-main">
<div class="crumbs picker-crumbs">
<span
class="crumb"
class:current=move || sel_dir.get().is_empty()
on:click=move |_| set_sel_dir.set(String::new())
>
{move || root_name(sel_root.get())}
</span>
{move || {
let segs = dir_segs(&sel_dir.get());
segs
.iter()
.enumerate()
.map(|(i, seg)| {
let seg = seg.clone();
let prefix: String = segs[..=i].join("/");
let is_last = i == segs.len() - 1;
view! {
<span class="crumb-sep">"/"</span>
<span
class="crumb"
class:current=is_last
on:click=move |_| set_sel_dir.set(prefix.clone())
>
{seg}
</span>
}
on:click=move |_| set_dir.set(prefix.clone())
>
{seg}
</span>
}
})
.collect::<Vec<_>>()
}}
</div>
})
.collect::<Vec<_>>()
}}
</div>
<div class="picker-entries">
{move || {
let es = entries.get();
if es.is_empty() {
view! {
<p class="muted center-note">
{i18n::tr(i18n::k::NO_SUBFOLDERS)}
</p>
}
.into_view()
.into_any()
} else {
es.iter()
.map(|e| {
let name = e.name.clone();
let name2 = name.clone();
view! {
<div
class="picker-row"
on:click=move |_| {
let cur = sel_dir2.get();
let newdir = if cur.is_empty() {
name2.clone()
} else {
format!("{cur}/{name2}")
};
set_dir.set(newdir);
}
>
<Icon name=IconName::Folder class="row-icon".to_string()/>
<span class="row-name">{name}</span>
<div class="picker-entries">
{move || {
let es = entries.get();
if loading.get() {
return view! {
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any();
}
if es.is_empty() {
return view! {
<div class="picker-empty muted">
<Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
<span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
</div>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}
}}
.into_view()
.into_any();
}
es.iter()
.map(|e| {
let name = e.name.clone();
let name2 = name.clone();
view! {
<div class="picker-row" on:click=move |_| descend(name2.clone())>
<Icon name=IconName::Folder class="row-icon".to_string()/>
<span class="row-name">{name}</span>
<span class="picker-chev"></span>
</div>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}}
</div>
</div>
</div>
<div class="modal-actions">
@@ -684,17 +679,15 @@ fn PickerDialog(
<button
class="btn btn-primary"
disabled=move || {
// Moving/copying into the exact same spot is a no-op.
sel_root2.get() == source_root && sel_dir2.get() == source_dir
let rid = sel_root.get();
for_write && (!root_writable(rid) || (rid == root && sel_dir.get() == dir))
}
on:click=move |_| {
let rid = sel_root2.get();
let dir = sel_dir2.get();
on_pick.run((rid, dir));
on_pick.run((sel_root.get(), sel_dir.get()));
on_close.run(());
}
>
{i18n::tr(op.here_key())}
{confirm}
</button>
</div>
</div>
Mweb/src/views/search.rs
@@ -1,6 +1,6 @@
//! The search view (`#/search`): index-free name and content search with
//! streamed results, an explicit stop button, a root select, and collapsible
//! per-file match lists.
//! streamed results, an explicit stop button, a start-folder picker, and
//! collapsible per-file match lists.
//!
//! Results arrive as they are found (SSE, see `api::search_stream`). The
//! client caps only what it *renders* (name rows a page of [`FILE_PAGE`] at a
@@ -27,6 +27,7 @@ use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::format_size;
use crate::views::dialogs::Dialog;
use crate::views::file_view::{FileView, UnsupportedTarget};
/// Name rows rendered at once, and how many more each click of the "show
@@ -269,8 +270,10 @@ fn push_escaped(out: &mut String, s: &str) {
}
}
/// Parse `#/search?q=...&scope=...&root=...` (the part after `?`).
fn parse_search_url() -> (String, Scope, Option<i64>) {
/// Parse `#/search?q=...&scope=...&root=...&path=...` (the part after `?`).
/// Returns the query, the scope and the start folder `(root, dir)` when a
/// root is given.
fn parse_search_url() -> (String, Scope, Option<(i64, String)>) {
let hash = web_sys::window()
.and_then(|w| w.location().hash().ok())
.unwrap_or_default();
@@ -285,19 +288,29 @@ fn parse_search_url() -> (String, Scope, Option<i64>) {
Some("content") => Scope::Content,
_ => Scope::Both,
};
let root = p.get("root").and_then(|s| s.trim().parse().ok());
(p.get("q").unwrap_or_default(), scope, root)
let folder = p
.get("root")
.and_then(|s| s.trim().parse().ok())
.map(|root| (root, p.get("path").unwrap_or_default()));
(p.get("q").unwrap_or_default(), scope, folder)
}
/// Write the current search into the hash without adding a history entry.
fn set_search_url(q: &str, scope: Scope, root: i64) {
let hash = format!(
"#/search?q={}&scope={}&root={root}",
fn search_hash(q: &str, scope: Scope, root: i64, dir: &str) -> String {
format!(
"#/search?q={}&scope={}&root={root}&path={}",
js_sys::encode_uri_component(q),
scope.param()
);
if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) {
let _ = hist.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&hash));
scope.param(),
js_sys::encode_uri_component(dir),
)
}
/// Open the search view with `dir` of `root` as the start folder and an
/// empty query (the browser's "search in this folder").
pub fn open_search_in(root: i64, dir: &str) {
if let Some(w) = web_sys::window() {
let _ = w
.location()
.set_hash(&search_hash("", Scope::Both, root, dir));
}
}
@@ -334,6 +347,8 @@ pub fn SearchView(
// While a file view is open it covers the content area, so the search
// UI hides (the view stays mounted: an in-flight search keeps running).
file_view: ReadSignal<Option<FileView>>,
/// The start-folder picker is a shell-level dialog.
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
// ---- state -------------------------------------------------------------
@@ -342,11 +357,11 @@ pub fn SearchView(
// The running search's query words, for highlighting. Not reactive: rows
// read it while they render, and it only changes with a new search.
let words = StoredValue::new(Vec::<String>::new());
// The one root a search covers. Defaults to the caller's first root; the
// `<select>` re-reads `me` live for the names.
let (sel_root, set_sel_root) = RwSignal::<Option<i64>>::new(
// The folder a search starts in: `(root id, dir relative to the root)`.
// Defaults to the top of the caller's first root.
let (folder, set_folder) = RwSignal::<Option<(i64, String)>>::new(
me.get_untracked()
.and_then(|m| m.roots.first().map(|r| r.id)),
.and_then(|m| m.roots.first().map(|r| (r.id, String::new()))),
)
.split();
let status = RwSignal::new(Status::Idle);
@@ -458,16 +473,16 @@ pub fn SearchView(
pending.lock().unwrap().clear();
let my_gen = search_gen.fetch_add(1, Ordering::SeqCst) + 1;
// Exactly one root. Falls back to the first available when
// Exactly one start folder. Falls back to the first root when
// nothing is selected yet (a deep link with an unknown id, or
// `me` not loaded when the view was built).
let Some(root) = sel_root.get_untracked().or_else(|| {
let Some((root, dir)) = folder.get_untracked().or_else(|| {
me.get_untracked()
.and_then(|m| m.roots.first().map(|r| r.id))
.and_then(|m| m.roots.first().map(|r| (r.id, String::new())))
}) else {
return;
};
set_sel_root.set(Some(root));
set_folder.set(Some((root, dir.clone())));
// Fresh state for this run.
files_w.set(Vec::new());
@@ -480,13 +495,21 @@ pub fn SearchView(
seen_match_files.lock().unwrap().clear();
registry.set(HashMap::new());
status.set(Status::Searching);
set_search_url(&q, scope, root);
// Write the search into the hash without adding a history entry.
if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) {
let _ = hist.replace_state_with_url(
&wasm_bindgen::JsValue::NULL,
"",
Some(&search_hash(&q, scope, root, &dir)),
);
}
words.set_value(query_words(&q));
let src = match api::search_stream(
q.clone(),
scope.param(),
root,
&dir,
on_stream_event,
on_error_for(toast, status, search_gen.clone(), my_gen),
) {
@@ -538,29 +561,56 @@ pub fn SearchView(
}
};
// Deep link: `#/search?q=...` restores and re-runs the search.
// Deep link: `#/search?root=…&path=…` sets the start folder (the
// browser's "search in this folder" arrives like this, with no query);
// with a `q` the search also re-runs.
{
let (q0, s0, r0) = parse_search_url();
let (q0, s0, f0) = parse_search_url();
// Only honour a root the caller actually has; otherwise keep the
// default so a stale link still searches something.
if let Some((id, dir)) = f0
&& me
.get_untracked()
.is_some_and(|m| m.roots.iter().any(|r| r.id == id))
{
set_folder.set(Some((id, dir)));
}
if !q0.is_empty() {
set_query.set(q0);
set_scope.set(s0);
// Only honour a root the caller actually has; otherwise keep the
// default so a stale link still searches something.
if let Some(id) = r0
&& me
.get_untracked()
.is_some_and(|m| m.roots.iter().any(|r| r.id == id))
{
set_sel_root.set(Some(id));
}
// Run the search from setup (an untracked context), not from an
// `Effect`: the effect would track `start_search`'s signal reads
// and retrigger on the very signal it writes (`sel_root`),
// and retrigger on the very signal it writes (`folder`),
// re-running the search in a loop and pegging the main thread.
start_search.run(());
}
}
// The start-folder button opens the shared folder picker.
let pick_folder = move |_| {
let Some(m) = me.get_untracked() else {
return;
};
let Some((root, dir)) = folder
.get_untracked()
.or_else(|| m.roots.first().map(|r| (r.id, String::new())))
else {
return;
};
set_dialog.set(Some(Dialog::Picker {
title: i18n::t(k::SEARCH_IN).to_string(),
confirm: i18n::t(k::SEARCH_HERE).to_string(),
roots: m.roots,
root,
dir,
for_write: false,
on_pick: Callback::new(move |(root, dir): (i64, String)| {
set_dialog.set(None);
set_folder.set(Some((root, dir)));
}),
}));
};
// Keep the scroll position across opening a result. The window is the
// scroller, and while a file is open the search UI is hidden, so the
// document shrinks to one viewport and the browser clamps the scroll to
@@ -692,37 +742,28 @@ pub fn SearchView(
{i18n::tr(k::SEARCH_BOTH)}
</button>
</div>
// A search covers exactly one root. A native `<select>`, as
// everywhere else in the app: the browser gives keyboard
// handling, and there is no "all" entry to misread.
<select
class="root-select"
on:change=move |ev: web_sys::Event| {
if let Some(id) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.and_then(|s| s.value().parse::<i64>().ok())
{
set_sel_root.set(Some(id));
}
}
// A search covers one folder (and everything below it). The
// button shows it as "Root/dir" and opens the folder picker.
<button
class="folder-pick"
title=move || i18n::t(k::SEARCH_IN).to_string()
on:click=pick_folder
>
{move || {
let cur = sel_root.get();
me.get()
.map(|m| m.roots)
.unwrap_or_default()
.into_iter()
.map(|r| {
view! {
<option value=r.id.to_string() selected=cur == Some(r.id)>
{r.name}
</option>
}
})
.collect::<Vec<_>>()
}}
</select>
<Icon name=IconName::Folder class="fp-icon".to_string()/>
<span class="fp-path">
{move || {
let Some((root, dir)) = folder.get() else {
return String::new();
};
let name = me
.get()
.and_then(|m| m.roots.into_iter().find(|r| r.id == root))
.map(|r| r.name)
.unwrap_or_default();
if dir.is_empty() { format!("{name}/") } else { format!("{name}/{dir}") }
}}
</span>
</button>
{move || {
if status.get() == Status::Searching {
view! {
Mweb/src/views/shell.rs
@@ -337,6 +337,7 @@ pub fn ShellView(
me=me
open_file=open_file
file_view=file_view
set_dialog=set_dialog
/>
}
.into_view()
Mweb/src/views/workspace.rs
@@ -31,8 +31,8 @@ pub struct FileWorkspace {
pub file_view: ReadSignal<Option<FileView>>,
/// Open a file in the full-page view (hand to the browser / search).
pub open_file: WriteSignal<Option<FileView>>,
pub selected: ReadSignal<Option<Entry>>,
pub set_selected: WriteSignal<Option<Entry>>,
pub selected: ReadSignal<Vec<Entry>>,
pub set_selected: WriteSignal<Vec<Entry>>,
pub dialog: ReadSignal<Option<Dialog>>,
pub set_dialog: WriteSignal<Option<Dialog>>,
/// The browser stores its fetch there so the editor can re-list after a
@@ -91,9 +91,9 @@ impl FileWorkspace {
let (saving, set_saving) = signal(false);
// The browser's fetch, exposed so the editor can re-list after a save.
let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
// The single selected entry in the file browser; its action buttons
// The selected entries in the file browser; their action buttons
// live in the top bar's top right.
let (selected, set_selected) = signal(Option::<Entry>::None);
let (selected, set_selected) = signal(Vec::<Entry>::new());
// Dialogs are rendered here, above everything.
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
let topbar_ref = NodeRef::<Header>::new();
@@ -101,7 +101,7 @@ impl FileWorkspace {
// Navigating (new folder, root, section) invalidates the selection.
Effect::new(move |_| {
let _ = loc.get();
set_selected.set(None);
set_selected.set(Vec::new());
});
let close_file = {