Milestone 3: mutations (mkdir/rename/move/copy/delete) + upload with overwrite conflict dialog

Server:
- fs.rs: resolve_dir, validate_name, mkdir/rename/remove/move/copy (+recursive), mtime preservation, conflict detection
- api/files.rs: POST dispatch (mkdir | rename/move/copy JSON | multipart upload), top-level upload route (dispatch_root), DELETE; multer streaming to temp file + atomic rename; 409 with skipped list
- error.rs: extra JSON field (skipped), From<io::Error>

Web:
- api.rs: mkdir/rename/move/copy/delete + upload (hand-rolled multipart as Blob — a FormData body makes Chrome stream the request (h2c/ALPN) which fails against HTTP/1.1; a pre-assembled Blob has a known length), pick_files (file/folder via webkitdirectory)
- views/dialogs.rs: Prompt/Confirm/Conflict/Picker dialogs (move/copy picker with root tabs + folder navigation + no-op guard + read-only labels)
- views/browser.rs: context-menu actions wired to dialogs; dialog callbacks created under the Browser Owner (menu event scopes are disposed when the menu closes); rw/ro gating on all menu items

Verified in browser: new folder, rename (+conflict overwrite), delete, cross-root copy, move (+conflict overwrite), upload conflict dialog, read-only gating.

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commitaee9fd425e2871ff9ee8d2c279f0f5187600d8d9
Parent77c102e
12 files changed, 2193 insertions(+), 73 deletions(-)
▾MCargo.lock
@@ -581,6 +581,15 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1395,6 +1404,25 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"tokio",
"tokio-util",
"version_check",
]
[[package]]
name = "next_tuple"
version = "0.1.0"
@@ -1969,6 +1997,7 @@ dependencies = [
"chrono",
"clap",
"mime_guess",
"multer",
"rand",
"rusqlite",
"rust-embed",
@@ -2117,6 +2146,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -2292,6 +2327,7 @@ version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
@@ -2312,6 +2348,19 @@ dependencies = [
"syn 3.0.4",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "1.1.4+spec-1.1.0"
▾Mserver/Cargo.toml
@@ -14,12 +14,13 @@ axum = "0.8"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
mime_guess = "2"
multer = { version = "3", features = ["tokio-io"] }
rand = "0.8"
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "fs"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "fs", "io-util"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
▾Mserver/src/api/files.rs
@@ -1,20 +1,55 @@
//! File operations API.
//!
//! All operations live on the same URL shape as the listing, distinguished by
//! method (and content type for POST):
//!
//! - `GET /api/files/{root_id}` and `/api/files/{root_id}/{*path}` — list
//! - `DELETE /api/files/{root_id}/{*path}` — delete a file or folder
//! - `POST /api/files/{root_id}/{*path}` — create a folder (no body)
//! - `POST /api/files/{root_id}/{*path}` (JSON body) — rename / move / copy
//! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir
use std::path::Component;
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::extract::{Path as AxumPath, State};
use axum::http::{header, StatusCode};
use axum::Json;
use multer::Multipart;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use crate::api::common::AuthUser;
use crate::db::RootRow;
use crate::error::{ApiError, AppState};
use crate::fs;
// ---------------------------------------------------------------------------
// Bodies / query params
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct MutationBody {
pub op: String, // "rename" | "move" | "copy"
#[serde(default)]
pub new_name: Option<String>,
#[serde(default)]
pub dst_root_id: Option<i64>,
#[serde(default)]
pub dst: Option<String>,
#[serde(default)]
pub overwrite: bool,
}
// ---------------------------------------------------------------------------
// Listing
// ---------------------------------------------------------------------------
/// GET /api/files/{root_id}/{*path}
///
/// List a directory inside one of the user's roots. The wildcard part is the
/// path relative to that root (percent-decoded by axum).
pub async fn list(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: Path<(i64, String)>,
path: AxumPath<(i64, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (root_id, req_rel) = path.0;
list_inner(state, auth, root_id, req_rel).await
@@ -24,7 +59,7 @@ pub async fn list(
pub async fn list_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: Path<i64>,
path: AxumPath<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
list_inner(state, auth, path.0, String::new()).await
}
@@ -35,26 +70,387 @@ async fn list_inner(
root_id: i64,
req_rel: String,
) -> Result<Json<serde_json::Value>, ApiError> {
let root = auth
.roots
.iter()
.find(|r| r.id == root_id)
.ok_or_else(|| {
crate::error::ApiError::new(
axum::http::StatusCode::FORBIDDEN,
"no such folder",
)
})?;
let root = find_root(&auth.roots, root_id)?;
let server_root = state.root.clone();
let root_rel = root.path.clone();
let full = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
.await
.map_err(|_| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
.await
.map_err(|_| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "entries": entries })))
}
// ---------------------------------------------------------------------------
// POST dispatch: mkdir | rename/move/copy | upload
// ---------------------------------------------------------------------------
/// POST /api/files/{root_id} — top-level operations (upload into the root
/// directory). The bare root never targets a specific item, so JSON ops with
/// a missing folder name are rejected by the individual handlers.
pub async fn dispatch_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
dispatch_inner(state, auth, path.0, String::new(), headers, req).await
}
/// POST /api/files/{root_id}/{*path}
pub async fn dispatch(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (root_id, req_rel) = path.0;
dispatch_inner(state, auth, root_id, req_rel, headers, req).await
}
async fn dispatch_inner(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
let ct = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if ct.starts_with("multipart/form-data") {
return upload(state, auth, root_id, req_rel, req).await;
}
if ct.starts_with("application/json") {
let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
let body: MutationBody = axum::Json::from_bytes(&bytes)
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
.0;
return mutation(state, auth, root_id, req_rel, body).await;
}
mkdir(state, auth, root_id, req_rel).await
}
// ---------------------------------------------------------------------------
// mkdir
// ---------------------------------------------------------------------------
async fn mkdir(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<serde_json::Value>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"a folder name is required",
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// rename / move / copy
// ---------------------------------------------------------------------------
async fn mutation(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
body: MutationBody,
) -> Result<Json<serde_json::Value>, ApiError> {
match body.op.as_str() {
"rename" => {
let new_name = body
.new_name
.as_deref()
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
.to_string();
let root = require_rw_root(&auth.roots, root_id)?;
let (server_root, root_rel, rel, overwrite) =
(state.root.clone(), root.path.clone(), req_rel, body.overwrite);
tokio::task::spawn_blocking(move || fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
}
"move" | "copy" => {
let dst_root_id = body
.dst_root_id
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
let dst = body
.dst
.clone()
.unwrap_or_default();
// Moving or copying out of a folder requires rw there; copying
// *from* a read-only root is fine.
let src_root = if body.op == "move" {
require_rw_root(&auth.roots, root_id)?
} else {
find_root(&auth.roots, root_id)?
};
let dst_root = require_rw_root(&auth.roots, dst_root_id)?;
let (server_root, src_rel, dst_rel, dst_path, rel, overwrite) = (
state.root.clone(),
src_root.path.clone(),
dst_root.path.clone(),
dst,
req_rel,
body.overwrite,
);
let op_is_move = body.op == "move";
tokio::task::spawn_blocking(move || {
if op_is_move {
fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
} else {
fs::copy_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
}
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
}
_ => Err(ApiError::new(
StatusCode::BAD_REQUEST,
"unknown op (expected rename, move or copy)",
)),
}
}
// ---------------------------------------------------------------------------
// DELETE
// ---------------------------------------------------------------------------
pub async fn delete(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (root_id, req_rel) = path.0;
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"a path inside the folder is required",
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let is_dir = tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
}
// ---------------------------------------------------------------------------
// Upload (multipart)
// ---------------------------------------------------------------------------
async fn upload(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
let base = {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??
};
let boundary = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(parse_boundary)
.ok_or_else(|| {
ApiError::new(
StatusCode::BAD_REQUEST,
"expected multipart/form-data with a boundary",
)
})?;
let overwrite = parse_overwrite(req.uri());
let stream = req.into_body().into_data_stream();
let mut multipart = Multipart::new(stream, boundary);
let mut uploaded: usize = 0;
let mut skipped: Vec<String> = Vec::new();
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
{
let part_name = field
.name()
.filter(|n| !n.is_empty())
.or_else(|| field.file_name())
.map(str::to_string)
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "part without a name"))?;
validate_rel_path(&part_name)?;
let target = base.join(&part_name);
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "invalid part name"))?;
if !parent.is_dir() {
let p = parent.to_path_buf();
tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
}
if target.exists() && !overwrite {
// Drain this part and report it as a conflict at the end.
while let Some(_chunk) = field
.chunk()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
{}
skipped.push(part_name);
continue;
}
if target.exists() {
if target.is_dir() {
return Err(ApiError::new(
StatusCode::CONFLICT,
"a folder with this name already exists",
));
}
tokio::fs::remove_file(&target)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
}
// Stream to a temp file in the same directory, then rename into place.
let suffix = crate::auth::random_token();
let tmp = parent.join(format!(".upload-{suffix}"));
let mut tmp_file = tokio::fs::File::create(&tmp)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let write_failed = loop {
match field
.chunk()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))
{
Ok(Some(chunk)) => {
if let Err(e) = tmp_file.write_all(&chunk).await {
tracing::warn!(error = %e, "write failed during upload");
break true;
}
}
Ok(None) => break false,
Err(e) => return Err(e),
}
};
if write_failed {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not save the file",
));
}
let tmp2 = tmp.clone();
let target2 = target.clone();
tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))
.map_err(|e| e)?
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, "rename failed during upload");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
})?;
uploaded += 1;
}
if uploaded == 0 && skipped.is_empty() {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"no files were uploaded",
));
}
if !skipped.is_empty() {
return Err(
ApiError::new(
StatusCode::CONFLICT,
"some files already exist",
)
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
);
}
Ok(Json(serde_json::json!({ "ok": true, "uploaded": uploaded })))
}
fn parse_boundary(content_type: &str) -> Option<String> {
content_type
.split(';')
.map(|s| s.trim())
.find_map(|s| s.strip_prefix("boundary="))
.map(|b| b.trim_matches('"').to_string())
.filter(|b| !b.is_empty())
}
fn parse_overwrite(uri: &axum::http::Uri) -> bool {
uri.query()
.map(|q| {
q.split('&')
.any(|kv| kv == "overwrite=true" || kv == "overwrite=1")
})
.unwrap_or(false)
}
fn validate_rel_path(name: &str) -> Result<(), ApiError> {
for c in std::path::Path::new(name).components() {
match c {
Component::Normal(_) => {}
_ => {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid file path in upload",
))
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn find_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
roots
.iter()
.find(|r| r.id == root_id)
.ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
}
fn require_rw_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
let root = find_root(roots, root_id)?;
if root.mode != "rw" {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
}
Ok(root)
}
▾Mserver/src/api/mod.rs
@@ -1,6 +1,6 @@
use std::sync::Arc;
use axum::routing::{get, post};
use axum::routing::{delete, get, post};
use axum::Router;
use crate::error::AppState;
@@ -18,6 +18,9 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/auth/setup", post(auth::setup))
.route("/api/files/{root_id}", get(files::list_root))
.route("/api/files/{root_id}/{*path}", get(files::list))
.route("/api/files/{root_id}", post(files::dispatch_root))
.route("/api/files/{root_id}/{*path}", post(files::dispatch))
.route("/api/files/{root_id}/{*path}", delete(files::delete))
.fallback(spa::fallback)
.with_state(state)
}
▾Mserver/src/error.rs
@@ -14,23 +14,41 @@ pub struct AppState {
pub https: bool,
}
/// API error. `extra` is optionally merged into the JSON body (e.g. a list of
/// conflicting file names on a 409).
#[derive(Debug, thiserror::Error)]
#[error("{1}")]
pub struct ApiError(pub StatusCode, pub String);
pub struct ApiError(pub StatusCode, pub String, pub Option<serde_json::Value>);
impl ApiError {
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
Self(status, msg.into())
Self(status, msg.into(), None)
}
pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
self.2 = Some(extra);
self
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.0,
axum::Json(serde_json::json!({ "error": self.1 })),
)
.into_response()
let mut body = serde_json::json!({ "error": self.1 });
if let Some(extra) = self.2 {
if let (Some(obj), Some(extra)) = (body.as_object_mut(), extra.as_object()) {
for (k, v) in extra {
obj.insert(k.clone(), v.clone());
}
}
}
(self.0, axum::Json(body)).into_response()
}
}
impl From<std::io::Error> for ApiError {
fn from(e: std::io::Error) -> Self {
tracing::error!(error = %e, "io error");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
▾Mserver/src/fs.rs
@@ -19,6 +19,10 @@ pub enum FsError {
Forbidden,
#[error("the configured folder no longer exists")]
RootMissing,
#[error("already exists")]
Conflict,
#[error("{0}")]
Invalid(String),
}
impl From<FsError> for ApiError {
@@ -29,6 +33,8 @@ impl From<FsError> for ApiError {
FsError::NotADirectory => S::BAD_REQUEST,
FsError::Forbidden => S::FORBIDDEN,
FsError::RootMissing => S::NOT_FOUND,
FsError::Conflict => S::CONFLICT,
FsError::Invalid(_) => S::BAD_REQUEST,
};
ApiError::new(status, e.to_string())
}
@@ -128,3 +134,213 @@ fn mtime_str(m: &std::fs::Metadata) -> String {
dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
}
// ---------------------------------------------------------------------------
// Mutations (milestone 3): mkdir, rename, remove, move, copy, upload
// ---------------------------------------------------------------------------
/// Resolve a directory that must exist (relative to a user root). Used as the
/// base for operations that target the *parent* of the item.
pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let full = resolve_path(server_root, root_rel, req_rel)?;
if !full.is_dir() {
return Err(FsError::NotADirectory);
}
Ok(full)
}
/// Validate a new single-component name (for rename / new folder).
pub fn validate_name(name: &str) -> Result<(), FsError> {
let p = Path::new(name);
if name.is_empty()
|| p.components().count() != 1
|| name == "."
|| name == ".."
|| name.contains(['/', '\\', '\0'])
{
return Err(FsError::Invalid("invalid name".to_string()));
}
Ok(())
}
/// Create a directory (and any missing parents) inside a user root.
pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
let full = resolve_path_or_new(server_root, root_rel, req_rel)?;
if full.exists() {
return Err(FsError::Conflict);
}
std::fs::create_dir_all(&full).map_err(|e| 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, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let root_abs = resolve_root(server_root, root_rel)?;
let req = Path::new(req_rel);
for c in req.components() {
if matches!(c, Component::ParentDir) {
return Err(FsError::Forbidden);
}
}
let full = root_abs.join(req);
// The parent must exist and stay inside the root.
let parent = full
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?;
ensure_within(&root_abs, &parent)?;
Ok(full)
}
/// Rename (or move within the same directory) an item.
pub fn rename_item(
server_root: &Path,
root_rel: &str,
req_rel: &str,
new_name: &str,
overwrite: bool,
) -> Result<(), FsError> {
validate_name(new_name)?;
let from = resolve_path(server_root, root_rel, req_rel)?;
let parent = from
.parent()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
let to = parent.join(new_name);
if to.exists() {
if !overwrite || to.is_dir() || from.is_dir() {
return Err(FsError::Conflict);
}
std::fs::remove_file(&to).map_err(|e| io_err(e, &to))?;
}
std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
Ok(())
}
/// Delete a file or a directory tree. Returns whether it was a directory.
pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<bool, FsError> {
let full = resolve_path(server_root, root_rel, req_rel)?;
let is_dir = full.is_dir();
if is_dir {
std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?;
} else {
std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?;
}
Ok(is_dir)
}
fn io_err(e: std::io::Error, p: &Path) -> FsError {
tracing::warn!(error = %e, path = %p.display(), "filesystem error");
match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
}
}
/// True if `a` is `b` or a descendant of `b` (both canonical).
fn is_within_or_eq(base: &Path, p: &Path) -> bool {
p == base || p.starts_with(base)
}
/// Move an item (possibly across roots). `dst_dir_rel` is the destination
/// directory (relative to `dst_root_rel`); the item keeps its base name.
pub fn move_item(
server_root: &Path,
src_root_rel: &str,
src_rel: &str,
dst_root_rel: &str,
dst_dir_rel: &str,
overwrite: bool,
) -> Result<(), FsError> {
let from = resolve_path(server_root, src_root_rel, src_rel)?;
let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
let name = from
.file_name()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
.to_owned();
let to = dst_dir.join(&name);
// Refuse moving a directory into itself or a descendant.
if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
return Err(FsError::Invalid(
"cannot move a folder into itself".to_string(),
));
}
check_move_conflict(&to, &from, overwrite)?;
match std::fs::rename(&from, &to) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
copy_recursive(&from, &to)?;
if from.is_dir() {
std::fs::remove_dir_all(&from).map_err(|_| FsError::Forbidden)?;
} else {
std::fs::remove_file(&from).map_err(|_| FsError::Forbidden)?;
}
Ok(())
}
Err(e) => Err(io_err(e, &to)),
}
}
/// Copy an item (possibly across roots).
pub fn copy_item(
server_root: &Path,
src_root_rel: &str,
src_rel: &str,
dst_root_rel: &str,
dst_dir_rel: &str,
overwrite: bool,
) -> Result<(), FsError> {
let from = resolve_path(server_root, src_root_rel, src_rel)?;
let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
let name = from
.file_name()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
.to_owned();
let to = dst_dir.join(&name);
if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
return Err(FsError::Invalid(
"cannot copy a folder into itself".to_string(),
));
}
check_move_conflict(&to, &from, overwrite)?;
copy_recursive(&from, &to)?;
Ok(())
}
/// Conflict rules shared by move and copy:
/// - target is a directory → always conflict (no silent merge)
/// - target is a file → conflict unless overwriting a file with a file
fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> {
if to.exists() {
let to_dir = to.is_dir();
let from_dir = from.is_dir();
if to_dir || from_dir || !overwrite {
return Err(FsError::Conflict);
}
}
Ok(())
}
/// Recursively copy a file or directory tree, preserving mtime.
pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?;
if meta.is_dir() {
std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?;
for e in std::fs::read_dir(src).map_err(|e| io_err(e, src))?.flatten() {
copy_recursive(&e.path(), &dst.join(e.file_name()))?;
}
} else {
std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?;
}
set_mtime(dst, meta.modified().ok());
Ok(())
}
fn set_mtime(p: &Path, t: Option<std::time::SystemTime>) {
if let (Some(t), Ok(f)) = (t, std::fs::File::open(p)) {
let _ = f.set_modified(t);
}
}
▾Mweb/Cargo.toml
@@ -15,9 +15,14 @@ thiserror = "2"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Blob",
"Document",
"EventTarget",
"File",
"FileList",
"HashChangeEvent",
"Headers",
"HtmlElement",
"HtmlInputElement",
"KeyboardEvent",
"Location",
▾Mweb/app.css
@@ -475,3 +475,124 @@ button:disabled {
max-width: 320px;
}
/* ---- modals ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 45%);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
width: 440px;
max-width: calc(100vw - 32px);
padding: 20px;
}
.modal.picker {
width: 520px;
}
.modal-title {
margin: 0 0 12px;
font-size: 16px;
}
.modal-label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
.modal .input {
width: 100%;
box-sizing: border-box;
margin-bottom: 16px;
}
.modal-message {
white-space: pre-line;
font-size: 14px;
margin: 0 0 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: color-mix(in srgb, var(--danger) 85%, black);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.conflict-list {
max-height: 200px;
overflow-y: auto;
margin: 8px 0;
padding-left: 20px;
font-size: 13px;
}
.picker-roots {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.root-opt {
font-size: 13px;
}
.root-opt.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.picker-current {
font-size: 13px;
margin: 0 0 8px;
}
.picker-crumbs {
margin-bottom: 8px;
}
.picker-entries {
max-height: 240px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.picker-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
}
.picker-row:hover {
background: var(--bg);
}
▾Mweb/src/api.rs
@@ -6,12 +6,28 @@ use wasm_bindgen_futures::JsFuture;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("{1}")]
Http(u16, String),
/// Non-2xx response. `skipped` carries the server's conflict file list
/// when present (upload conflicts).
#[error("{message}")]
Http {
#[allow(dead_code)]
status: u16,
message: String,
skipped: Option<Vec<String>>,
},
#[error("network error: {0}")]
Net(String),
}
impl ApiError {
pub fn skipped(&self) -> Option<&[String]> {
match self {
ApiError::Http { skipped: Some(s), .. } => Some(s),
_ => None,
}
}
}
#[derive(Deserialize, Clone)]
pub struct Me {
pub first_boot: bool,
@@ -23,7 +39,7 @@ pub struct Me {
#[derive(Deserialize, Clone)]
pub struct UserInfo {
#[allow(dead_code)] // used from milestone 2 onwards
#[allow(dead_code)] // used from milestone 7 onwards
pub id: i64,
pub name: String,
pub is_admin: bool,
@@ -31,7 +47,6 @@ pub struct UserInfo {
#[derive(Deserialize, Clone)]
pub struct RootInfo {
#[allow(dead_code)] // used from milestone 2 onwards
pub id: i64,
pub name: String,
pub path: String,
@@ -51,6 +66,12 @@ pub struct FilesResp {
pub entries: Vec<Entry>,
}
#[derive(Deserialize)]
pub struct UploadResp {
#[allow(dead_code)]
pub uploaded: usize,
}
/// Acknowledges a successful 2xx response whose body we don't care about.
/// Accepts any JSON value (the server sends `{"ok": true}`).
pub struct OkResp;
@@ -68,6 +89,19 @@ struct CredentialsBody {
password: String,
}
/// Server error body: `{"error": "...", "skipped": [...]?}`.
#[derive(Deserialize, Default)]
struct ErrBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
skipped: Option<Vec<String>>,
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
request("GET", "/api/auth/me".to_string(), None::<()>)
}
@@ -98,11 +132,12 @@ pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", "/api/auth/logout".to_string(), Some(()))
}
pub fn list_files(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
let url = if path.is_empty() {
// ---------------------------------------------------------------------------
// Files
// ---------------------------------------------------------------------------
fn files_url(root_id: i64, path: &str) -> String {
if path.is_empty() {
format!("/api/files/{root_id}")
} else {
let encoded: Vec<String> = path
@@ -110,10 +145,263 @@ pub fn list_files(
.map(|s| js_sys::encode_uri_component(s).into())
.collect();
format!("/api/files/{root_id}/{}", encoded.join("/"))
}
}
pub fn list_files(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
request("GET", files_url(root_id, path), None::<()>)
}
/// Create a folder. `path` is relative to the root (may contain subfolders).
pub fn mkdir(root_id: i64, path: &str) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("POST", files_url(root_id, path))
}
pub fn rename_item(
root_id: i64,
path: &str,
new_name: String,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
files_url(root_id, path),
Some(Mutation {
op: "rename".to_string(),
new_name: Some(new_name),
dst_root_id: None,
dst: None,
overwrite,
}),
)
}
pub fn move_item(
root_id: i64,
path: &str,
dst_root_id: i64,
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, "move", dst_root_id, dst, overwrite)
}
pub fn copy_item(
root_id: i64,
path: &str,
dst_root_id: i64,
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, "copy", dst_root_id, dst, overwrite)
}
fn mutation(
root_id: i64,
path: &str,
op: &str,
dst_root_id: i64,
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
files_url(root_id, path),
Some(Mutation {
op: op.to_string(),
new_name: None,
dst_root_id: Some(dst_root_id),
dst: Some(dst.to_string()),
overwrite,
}),
)
}
#[derive(Serialize)]
struct Mutation {
op: String,
#[serde(skip_serializing_if = "Option::is_none")]
new_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
dst_root_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
dst: Option<String>,
overwrite: bool,
}
pub fn delete_item(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("DELETE", files_url(root_id, path))
}
/// Upload files into a directory. Each part is `(relative_path, file)`;
/// the relative path may contain subfolders (created on the server).
///
/// The multipart body is assembled by hand into a `Blob` (instead of using
/// `FormData` directly as the fetch body): a FormData body makes Chrome send
/// the request as a *streaming* body, which forces the HTTP/2-cleartext
/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
/// it goes out as a regular length-prefixed HTTP/1.1 request.
pub async fn upload(
root_id: i64,
dir: &str,
overwrite: bool,
parts: Vec<(String, web_sys::File)>,
) -> Result<UploadResp, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let boundary = format!("----fbng{}", random_boundary_suffix());
let segments = js_sys::Array::new();
for (name, file) in &parts {
let basename = name.rsplit('/').next().unwrap_or(name);
segments.push(&JsValue::from_str(&format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
Content-Type: application/octet-stream\r\n\r\n"
)));
let f: JsValue = file.clone().unchecked_into();
segments.push(&f);
segments.push(&JsValue::from_str("\r\n"));
}
segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
.map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
let url = format!(
"{}?overwrite={}",
files_url(root_id, dir),
if overwrite { "true" } else { "false" }
);
let headers = web_sys::Headers::new()
.map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
headers
.set(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
)
.map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts.set_headers_headers(&headers);
opts.set_body_opt_blob(Some(&body));
let promise = window.fetch_with_str_and_init(&url, &opts);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "upload failed".to_string()),
skipped: body.skipped,
});
}
let js = resp
.json()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(js)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
}
// ---------------------------------------------------------------------------
// File picker (imperative, one at a time)
// ---------------------------------------------------------------------------
fn random_boundary_suffix() -> String {
let mut s = String::with_capacity(16);
for _ in 0..16 {
let n = (js_sys::Math::random() * 36.0) as u32;
s.push(char::from_digit(n, 36).unwrap_or('a'));
}
s
}
use wasm_bindgen::closure::Closure;
/// Open the native file dialog and run `on_files` with the picked files once
/// the user confirms. `directory` uses webkitdirectory (folder upload).
fn webkit_relative_path(file: &web_sys::File) -> String {
let js: JsValue = file.into();
js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
.ok()
.and_then(|v| v.as_string())
.filter(|s| !s.is_empty())
.unwrap_or_default()
}
pub fn pick_files(
multiple: bool,
directory: bool,
on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let Ok(el) = doc.create_element("input") else {
return;
};
request("GET", url, None::<()>)
let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
return;
};
input.set_type("file");
if multiple {
input.set_multiple(true);
}
if directory {
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.
let input2 = input.clone();
let closure = Closure::<dyn FnMut()>::new(move || {
let mut files: Vec<(String, web_sys::File)> = Vec::new();
if let Some(list) = input.files() {
for i in 0..list.length() {
if let Some(f) = list.get(i) {
let rel = webkit_relative_path(&f);
let name = if rel.is_empty() { f.name() } else { rel };
files.push((name, f));
}
}
}
let _ = input.remove();
if !files.is_empty() {
on_files(files);
}
});
let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
let _ = input2.add_event_listener_with_callback("change", listener);
closure.forget();
if let Some(body) = doc.body() {
let _ = body.append_child(&input2);
}
input2.click();
}
// ---------------------------------------------------------------------------
// Low-level request helpers
// ---------------------------------------------------------------------------
async fn request<T: DeserializeOwned>(
method: &str,
url: String,
@@ -135,9 +423,30 @@ async fn request<T: DeserializeOwned>(
opts.set_headers_headers(&headers);
}
let req =
web_sys::Request::new_with_str_and_init(&url, &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
do_fetch(window, url, opts).await
}
/// Request without a body (mkdir / delete).
async fn request_no_body<T: DeserializeOwned>(
method: &str,
url: String,
) -> Result<T, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_method(method);
opts.set_mode(web_sys::RequestMode::SameOrigin);
do_fetch(window, url, opts).await
}
async fn do_fetch<T: DeserializeOwned>(
window: web_sys::Window,
url: String,
opts: web_sys::RequestInit,
) -> Result<T, ApiError> {
let req = web_sys::Request::new_with_str_and_init(&url, &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
@@ -148,7 +457,12 @@ async fn request<T: DeserializeOwned>(
let status = resp.status();
if !(200..300).contains(&status) {
return Err(ApiError::Http(status, error_message(&resp).await));
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "request failed".to_string()),
skipped: body.skipped,
});
}
let json_promise = resp
@@ -161,32 +475,42 @@ async fn request<T: DeserializeOwned>(
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
}
/// Best-effort extraction of the server's `{"error": "..."}` message.
async fn error_message(resp: &web_sys::Response) -> String {
/// Parse the server's error JSON (message + optional conflict list).
async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
let Ok(promise) = resp.text() else {
return "request failed".to_string();
return ErrBody::default();
};
let Ok(js) = JsFuture::from(promise).await else {
return "request failed".to_string();
return ErrBody::default();
};
let Some(text) = js.as_string() else {
return "request failed".to_string();
return ErrBody::default();
};
if let Ok(v) = js_sys::JSON::parse(&text) {
if let Ok(body) = serde_wasm_bindgen::from_value::<ErrBody>(v) {
return body;
}
}
// Fallback: naive extraction of the "error" string.
const MARKER: &str = "\"error\":\"";
if let Some(start) = text.find(MARKER) {
let error = text.find(MARKER).and_then(|start| {
let rest = &text[start + MARKER.len()..];
if let Some(end) = rest.find('"') {
return rest[..end].replace("\\\"", "\"");
}
rest.find('"').map(|end| rest[..end].replace("\\\"", "\""))
});
ErrBody {
error,
skipped: None,
}
"request failed".to_string()
}
/// Read a form input's value by element id.
pub fn input_value(id: &str) -> String {
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(id).and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok()))
.and_then(|d| {
d.get_element_by_id(id)
.and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
})
.map(|i| i.value())
.unwrap_or_default()
}
▾Mweb/src/views/browser.rs
@@ -9,6 +9,7 @@ use crate::components::icon::{icon_for, Icon, IconName};
use crate::components::toast::{show, ToastMsg};
use crate::router::{navigate, Location};
use crate::util::{format_date, format_size, ViewMode};
use crate::views::dialogs::{Dialog, DialogView, Op};
#[derive(Clone, Debug, PartialEq)]
enum ListState {
@@ -44,6 +45,11 @@ pub fn Browser(
let (view_mode, set_view_mode) = signal(ViewMode::load());
let (list_state, set_list_state) = signal(ListState::Loading);
let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
// Long-lived owner for callbacks created inside short-lived scopes
// (context-menu event handlers); otherwise they get disposed with the
// menu and the open dialog panics on submit.
let me_owner = Owner::current().unwrap();
// Fetch the current directory whenever the location (or user) changes.
let fetch = Callback::new(move |_| {
@@ -91,9 +97,14 @@ pub fn Browser(
<CtxMenuView
ctx=ctx
set_ctx=set_ctx
me=me
loc=loc
refresh=fetch
toast=toast
set_dialog=set_dialog
owner=me_owner
/>
<DialogView dialog=dialog set_dialog=set_dialog/>
</div>
}
}
@@ -417,15 +428,19 @@ fn list_view(
}
// ---------------------------------------------------------------------------
// Context menu (M2 shell: only "Open" and "Refresh" work yet)
// Context menu
// ---------------------------------------------------------------------------
#[component]
fn CtxMenuView(
ctx: ReadSignal<Option<CtxMenu>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> impl IntoView {
// Close on any click or Escape.
{
@@ -459,45 +474,141 @@ fn CtxMenuView(
let x = m.x.min(max_x).max(0);
let y = m.y.min(max_y).max(0);
let items: Vec<(&'static str, Option<Callback<()>>)> = match &m.entry {
let me_now = me.get();
let loc_now = loc.get();
let is_rw = me_now
.as_ref()
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.mode == "rw")
.unwrap_or(false);
// (label, action, disabled-hint)
let items: Vec<(String, Option<Callback<()>>, Option<String>)> = match &m.entry {
Some(e) => {
let mut v: Vec<(&'static str, Option<Callback<()>>)> = vec![];
let e2 = e.clone();
let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
if e.is_dir {
let name = e.name.clone();
let l = loc;
let name = e2.name.clone();
v.push((
"Open",
"Open".into(),
Some(Callback::new(move |_| {
let cur = l.get();
let mut path = cur.path;
path.push(name.clone());
// Open relative to the same root.
navigate(&Location {
root_id: cur.root_id,
path,
});
})),
None,
));
}
for label in [
"Download", "Rename", "Move", "Copy", "Share", "Info", "Delete",
] {
v.push((label, None));
}
v.push(("Download".into(), None, Some("Coming soon".into())));
let e_rename = e2.clone();
let l_rename = loc;
v.push((
"Rename".into(),
rw_action(
is_rw,
action_rename(&e_rename, l_rename, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
let e_move = e2.clone();
let l_move = loc;
let m_move = me;
v.push((
"Move".into(),
rw_action(
is_rw,
action_move_copy(
Op::Move,
&e_move,
m_move,
l_move,
refresh,
toast,
set_dialog,
owner.clone(),
),
),
Some("Read-only folder".into()),
));
let e3 = e2.clone();
let l_copy = loc;
let m_copy = me;
v.push((
"Copy".into(),
Some(action_move_copy(
Op::Copy,
&e3,
m_copy,
l_copy,
refresh,
toast,
set_dialog,
owner.clone(),
)),
None,
));
v.push(("Share".into(), None, Some("Coming soon".into())));
v.push(("Info".into(), None, Some("Coming soon".into())));
let e4 = e2.clone();
let l_del = loc;
v.push((
"Delete".into(),
rw_action(
is_rw,
action_delete(&e4, l_del, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
v
}
None => {
let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
let l_nf = loc;
v.push((
"New folder".into(),
rw_action(
is_rw,
action_new_folder(l_nf, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
let l_up1 = loc;
v.push((
"Upload files".into(),
rw_action(
is_rw,
action_upload(true, false, l_up1, refresh, toast, set_dialog),
),
Some("Read-only folder".into()),
));
let l_up2 = loc;
v.push((
"Upload folder".into(),
rw_action(
is_rw,
action_upload(true, true, l_up2, refresh, toast, set_dialog),
),
Some("Read-only folder".into()),
));
v.push((
"Refresh".into(),
Some(Callback::new(move |_| refresh.run(()))),
None,
));
v
}
None => vec![
("New folder", None),
("Upload", None),
("Refresh", Some(Callback::new(move |_| refresh.run(())))),
],
};
view! {
<div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
{items.into_iter().map(|(label, action)| {
{items.into_iter().map(|(label, action, hint)| {
view! {
<CtxItem label=label.to_string() action=action/>
<CtxItem label=label action=action hint=hint/>
}
}).collect::<Vec<_>>()}
</div>
@@ -508,9 +619,19 @@ fn CtxMenuView(
}
}
/// `Some(cb)` when the current folder is writable, else `None` (disabled item).
fn rw_action(is_rw: bool, cb: Callback<()>) -> Option<Callback<()>> {
if is_rw {
Some(cb)
} else {
None
}
}
#[component]
fn CtxItem(label: String, action: Option<Callback<()>>) -> impl IntoView {
fn CtxItem(label: String, action: Option<Callback<()>>, hint: Option<String>) -> impl IntoView {
let enabled = action.is_some();
let hint = hint.unwrap_or_else(|| "Coming soon".to_string());
view! {
<div
class=move || {
@@ -520,7 +641,7 @@ fn CtxItem(label: String, action: Option<Callback<()>>) -> impl IntoView {
"ctx-item disabled".to_string()
}
}
title=move || if enabled { String::new() } else { "Coming soon".to_string() }
title=move || if enabled { String::new() } else { hint.clone() }
on:click=move |_| {
if let Some(a) = &action {
a.run(());
@@ -531,3 +652,326 @@ fn CtxItem(label: String, action: Option<Callback<()>>) -> impl IntoView {
</div>
}
}
// ---------------------------------------------------------------------------
// Menu actions
// ---------------------------------------------------------------------------
fn join_path(dir: &[String], name: &str) -> String {
if dir.is_empty() {
name.to_string()
} else {
format!("{}/{}", dir.join("/"), name)
}
}
fn is_conflict(e: &api::ApiError) -> bool {
matches!(e, api::ApiError::Http { status: 409, .. })
}
fn action_new_folder(
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
Callback::new(move |_| {
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.clone();
set_dialog.set(Some(Dialog::Prompt {
title: "New folder".into(),
label: "Folder name".into(),
initial: String::new(),
submit: "Create".into(),
on_submit: 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, "Folder created");
refresh.run(());
}
Err(e) => show(toast, &e.to_string()),
}
});
})
}),
}));
})
}
fn action_rename(
entry: &Entry,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
Callback::new(move |_| {
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.clone();
let full = join_path(&dir, &name);
set_dialog.set(Some(Dialog::Prompt {
title: format!("Rename \u{201c}{name}\u{201d}"),
label: "New name".into(),
initial: name.clone(),
submit: "Rename".into(),
on_submit: 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 f1 = full.clone();
let f2 = full.clone();
spawn_local(async move {
match api::rename_item(root_id, &f1, new_name.clone(), false).await {
Ok(_) => {
show(toast, "Renamed");
refresh.run(());
}
Err(e) => {
if is_conflict(&e) {
set_dialog.set(Some(Dialog::Conflict {
title: "Name already in use".into(),
files: vec![new_name.clone()],
on_submit: Callback::new(move |_| {
let f3 = f2.clone();
let n2 = new_name.clone();
spawn_local(async move {
match api::rename_item(root_id, &f3, n2, true).await {
Ok(_) => {
show(toast, "Renamed");
refresh.run(());
}
Err(e2) => show(toast, &e2.to_string()),
}
});
}),
}));
} else {
show(toast, &e.to_string());
}
}
}
});
})
}),
}));
})
}
fn action_delete(
entry: &Entry,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
let is_dir = entry.is_dir;
Callback::new(move |_| {
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.clone();
let full = join_path(&dir, &name);
let message = if is_dir {
format!(
"Delete folder \u{201c}{name}\u{201d} and everything inside it?\nThis cannot be undone."
)
} else {
format!("Delete \u{201c}{name}\u{201d}?\nThis cannot be undone.")
};
set_dialog.set(Some(Dialog::Confirm {
title: "Delete".into(),
message,
submit: "Delete".into(),
danger: true,
on_submit: owner.with(|| {
Callback::new(move |_| {
set_dialog.set(None);
let f = full.clone();
spawn_local(async move {
match api::delete_item(root_id, &f).await {
Ok(_) => {
show(toast, "Deleted");
refresh.run(());
}
Err(e) => show(toast, &e.to_string()),
}
});
})
}),
}));
})
}
fn action_upload(
multiple: bool,
directory: bool,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
) -> Callback<()> {
Callback::new(move |_| {
let loc = loc.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let dir = loc.path.join("/");
let dir1 = dir.clone();
api::pick_files(multiple, directory, move |files| {
let n = files.len();
let retry = files.clone();
let dir2 = dir1.clone();
show(
toast,
&format!("Uploading {n} file{}\u{2026}", if n == 1 { "" } else { "s" }),
);
spawn_local(async move {
match api::upload(root_id, &dir2, false, files).await {
Ok(_) => {
show(toast, "Upload complete");
refresh.run(());
}
Err(e) => {
if let Some(skipped) = e.skipped() {
let skipped = skipped.to_vec();
set_dialog.set(Some(Dialog::Conflict {
title: "Some files already exist".into(),
files: skipped,
on_submit: Callback::new(move |_| {
let rf = retry.clone();
let dir3 = dir2.clone();
spawn_local(async move {
match api::upload(root_id, &dir3, true, rf).await {
Ok(_) => {
show(toast, "Upload complete");
refresh.run(());
}
Err(e2) => show(toast, &e2.to_string()),
}
});
}),
}));
} else {
show(toast, &e.to_string());
}
}
}
});
});
})
}
fn action_move_copy(
op: Op,
entry: &Entry,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
let entry2 = entry.clone();
Callback::new(move |_| {
let loc = loc.get();
let me = me.get();
let root_id = match loc.root_id {
Some(id) => id,
None => return,
};
let roots = me.as_ref().map(|m| m.roots.clone()).unwrap_or_default();
let dir = loc.path.clone();
let full = join_path(&dir, &name);
let dir_str = loc.path.join("/");
let name3 = name.clone();
let entry3 = entry2.clone();
let full3 = full.clone();
set_dialog.set(Some(Dialog::Picker {
op,
entry: entry3,
roots,
source_root: root_id,
source_dir: dir_str,
on_pick: owner.with(|| Callback::new(move |(dst_root, dst_dir): (i64, String)| {
set_dialog.set(None);
let f1 = full3.clone();
let f2 = full3.clone();
let name4 = name3.clone();
spawn_local(async move {
let res = if op == Op::Move {
api::move_item(root_id, &f1, dst_root, &dst_dir, false).await
} else {
api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await
};
match res {
Ok(_) => {
show(toast, if op == Op::Move { "Moved" } else { "Copied" });
refresh.run(());
}
Err(e) => {
if is_conflict(&e) {
set_dialog.set(Some(Dialog::Conflict {
title: "Name already in use".into(),
files: vec![name4],
on_submit: Callback::new(move |_| {
let f3 = f2.clone();
let d2 = dst_dir.clone();
spawn_local(async move {
let res = if op == Op::Move {
api::move_item(root_id, &f3, dst_root, &d2, true)
.await
} else {
api::copy_item(root_id, &f3, dst_root, &d2, true)
.await
};
match res {
Ok(_) => {
show(
toast,
if op == Op::Move { "Moved" } else { "Copied" },
);
refresh.run(());
}
Err(e2) => show(toast, &e2.to_string()),
}
});
}),
}));
} else {
show(toast, &e.to_string());
}
}
}
});
})),
}));
})
}
▾Aweb/src/views/dialogs.rs
@@ -0,0 +1,542 @@
//! Modal dialogs: text prompts (new folder / rename), confirmations (delete),
//! overwrite conflict warnings, and the move/copy destination picker.
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::{Icon, IconName};
/// Which mutating operation the destination picker is for.
#[derive(Clone, Copy, PartialEq)]
pub enum Op {
Move,
Copy,
}
impl Op {
pub fn verb(self) -> &'static str {
match self {
Op::Move => "Move",
Op::Copy => "Copy",
}
}
}
/// The single active dialog. The browser renders whichever is `Some`.
#[derive(Clone)]
pub enum Dialog {
/// Text input (new folder, rename).
Prompt {
title: String,
label: String,
initial: String,
submit: String,
on_submit: Callback<String>,
},
/// Simple confirmation (delete).
Confirm {
title: String,
message: String,
submit: String,
danger: bool,
on_submit: Callback<()>,
},
/// Overwrite warning listing the conflicting files.
Conflict {
title: String,
files: Vec<String>,
on_submit: Callback<()>,
},
/// Move/copy destination picker.
Picker {
op: Op,
entry: Entry,
roots: Vec<RootInfo>,
source_root: i64,
source_dir: String,
on_pick: Callback<(i64, String)>,
},
}
/// Split a "/"-separated directory path into its non-empty segments.
fn dir_segs(dir: &str) -> Vec<String> {
dir.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
#[component]
pub fn DialogView(
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
view! {
{move || {
let Some(d) = dialog.get() else {
return view! {}.into_any();
};
let close = Callback::new(move |_| set_dialog.set(None));
match &d {
Dialog::Prompt {
title,
label,
initial,
submit,
on_submit,
} => {
let (title, label, initial, submit, on_submit, close) = (
title.clone(),
label.clone(),
initial.clone(),
submit.clone(),
on_submit.clone(),
close.clone(),
);
view! {
<PromptDialog
title=title
label=label
initial=initial
submit=submit
on_submit=on_submit
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Confirm {
title,
message,
submit,
danger,
on_submit,
} => {
let (title, message, submit, danger, on_submit, close) = (
title.clone(),
message.clone(),
submit.clone(),
*danger,
on_submit.clone(),
close.clone(),
);
view! {
<ConfirmDialog
title=title
message=message
submit=submit
danger=danger
on_submit=on_submit
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Conflict {
title,
files,
on_submit,
} => {
let (title, files, on_submit, close) = (
title.clone(),
files.clone(),
on_submit.clone(),
close.clone(),
);
view! {
<ConflictDialog
title=title
files=files
on_submit=on_submit
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Picker {
op,
entry,
roots,
source_root,
source_dir,
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.clone(),
close.clone(),
);
view! {
<PickerDialog
op=op
entry=entry
roots=roots
source_root=source_root
source_dir=source_dir
on_pick=on_pick
on_close=close
/>
}
.into_view()
.into_any()
}
}
}}
}
}
// ---------------------------------------------------------------------------
// Prompt (text input)
// ---------------------------------------------------------------------------
#[component]
fn PromptDialog(
title: String,
label: String,
initial: String,
submit: String,
on_submit: Callback<String>,
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;
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{title}</h2>
<label class="modal-label" for=input_id>{label}</label>
<input
id=input_id
class="input"
value=initial
on:keydown=move |e: web_sys::KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
submit_cb.run(api::input_value(&id));
}
}
/>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
</button>
<button
class="btn btn-primary"
on:click=move |_| submit_cb.run(api::input_value(&id2))
>
{submit}
</button>
</div>
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Confirm
// ---------------------------------------------------------------------------
#[component]
fn ConfirmDialog(
title: String,
message: String,
submit: String,
danger: bool,
on_submit: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">{message}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
</button>
<button
class=move || {
if danger {
"btn btn-danger".to_string()
} else {
"btn btn-primary".to_string()
}
}
on:click=move |_| {
on_submit.run(());
on_close.run(());
}
>
{submit}
</button>
</div>
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Conflict (overwrite warning)
// ---------------------------------------------------------------------------
#[component]
fn ConflictDialog(
title: String,
files: Vec<String>,
on_submit: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
let shown: Vec<String> = files.iter().take(20).cloned().collect();
let extra = files.len().saturating_sub(shown.len());
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">
"The following files already exist. Overwrite them?"
</p>
<ul class="conflict-list">
{shown.iter().map(|f| {
let f = f.clone();
view! { <li>{f}</li> }
}).collect::<Vec<_>>()}
{move || {
if extra > 0 {
view! { <li class="muted">format!("…and {extra} more") </li> }
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</ul>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
</button>
<button
class="btn btn-danger"
on:click=move |_| {
on_submit.run(());
on_close.run(());
}
>
"Overwrite"
</button>
</div>
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Picker (move / copy destination)
// ---------------------------------------------------------------------------
#[component]
fn PickerDialog(
op: Op,
entry: Entry,
roots: Vec<RootInfo>,
source_root: i64,
source_dir: String,
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 (entries, set_entries) = signal(Vec::<Entry>::new());
// 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()),
}
});
});
}
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 || format!("{} “{}” to…", op.verb(), entry.name)}
</h2>
<div class="picker-roots">
{roots.iter().map(|r| {
let rid = r.id;
let ro = r.mode == "ro";
let name = if ro {
format!("{} (read-only)", 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()
}
}
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>
// Breadcrumbs within the current directory.
<div class="crumbs picker-crumbs">
<span
class="crumb"
on:click=move |_| set_dir.set(String::new())
>
"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()
}
}
on:click=move |_| set_dir.set(prefix.clone())
>
{seg}
</span>
}
})
.collect::<Vec<_>>()
}}
</div>
<div class="picker-entries">
{move || {
let es = entries.get();
if es.is_empty() {
view! {
<p class="muted center-note">
"No subfolders — the file will be placed in the current folder."
</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=Some("row-icon".into())/>
<span class="row-name">{name}</span>
</div>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}
}}
</div>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
"Cancel"
</button>
<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
}
on:click=move |_| {
let rid = sel_root2.get();
let dir = sel_dir2.get();
on_pick.run((rid, dir));
on_close.run(());
}
>
{move || format!("{} here", op.verb())}
</button>
</div>
</div>
</div>
}
}
▾Mweb/src/views/mod.rs
@@ -1,4 +1,5 @@
pub mod browser;
pub mod dialogs;
pub mod login;
pub mod setup;
pub mod shell;