browser.rs
| 1 | //! The file browser: breadcrumbs, grid/list views, root picker, context menu. |
| 2 | |
| 3 | use leptos::prelude::*; |
| 4 | use wasm_bindgen_futures::spawn_local; |
| 5 | use web_sys::MouseEvent; |
| 6 | |
| 7 | use crate::api::{self, Entry, Me, RootInfo}; |
| 8 | use crate::components::icon::{icon_for, Icon, IconName}; |
| 9 | use crate::components::toast::{show, ToastMsg}; |
| 10 | use crate::router::{navigate, Location}; |
| 11 | use crate::util::{format_date, format_size, ViewMode}; |
| 12 | |
| 13 | #[derive(Clone, Debug, PartialEq)] |
| 14 | enum ListState { |
| 15 | Loading, |
| 16 | Error(String), |
| 17 | Entries(Vec<Entry>), |
| 18 | } |
| 19 | |
| 20 | /// The root that is actually shown: the one in the location, or — when the |
| 21 | /// user has exactly one root — that root directly. |
| 22 | fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> { |
| 23 | match loc.root_id { |
| 24 | Some(id) => roots.iter().find(|r| r.id == id), |
| 25 | None if roots.len() == 1 => Some(&roots[0]), |
| 26 | None => None, |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | #[derive(Clone)] |
| 31 | struct CtxMenu { |
| 32 | x: i32, |
| 33 | y: i32, |
| 34 | /// None = empty area / root tile (no entry selected). |
| 35 | entry: Option<Entry>, |
| 36 | } |
| 37 | |
| 38 | #[component] |
| 39 | pub fn Browser( |
| 40 | me: ReadSignal<Option<Me>>, |
| 41 | loc: ReadSignal<Location>, |
| 42 | ) -> impl IntoView { |
| 43 | let toast = use_context::<ToastMsg>().expect("toast context"); |
| 44 | let (view_mode, set_view_mode) = signal(ViewMode::load()); |
| 45 | let (list_state, set_list_state) = signal(ListState::Loading); |
| 46 | let (ctx, set_ctx) = signal(Option::<CtxMenu>::None); |
| 47 | |
| 48 | // Fetch the current directory whenever the location (or user) changes. |
| 49 | let fetch = Callback::new(move |_| { |
| 50 | let Some(me) = me.get() else { |
| 51 | return; |
| 52 | }; |
| 53 | let loc = loc.get(); |
| 54 | let Some(root) = effective_root(&me.roots, &loc) else { |
| 55 | return; |
| 56 | }; |
| 57 | set_list_state.set(ListState::Loading); |
| 58 | let root_id = root.id; |
| 59 | let path_str = loc.path.join("/"); |
| 60 | spawn_local(async move { |
| 61 | match api::list_files(root_id, &path_str).await { |
| 62 | Ok(r) => set_list_state.set(ListState::Entries(r.entries)), |
| 63 | Err(e) => set_list_state.set(ListState::Error(e.to_string())), |
| 64 | } |
| 65 | }); |
| 66 | }); |
| 67 | Effect::new(move |_| fetch.run(())); |
| 68 | |
| 69 | view! { |
| 70 | <div class="browser"> |
| 71 | {move || { |
| 72 | let Some(me) = me.get() else { |
| 73 | return view! {}.into_any(); |
| 74 | }; |
| 75 | let loc_now = loc.get(); |
| 76 | match effective_root(&me.roots, &loc_now) { |
| 77 | None => root_picker(&me.roots, &set_ctx, &loc_now).into_any(), |
| 78 | Some(root) => file_browser( |
| 79 | root, |
| 80 | &loc, |
| 81 | &list_state, |
| 82 | &view_mode, |
| 83 | &set_view_mode, |
| 84 | &set_ctx, |
| 85 | &toast, |
| 86 | &fetch, |
| 87 | ) |
| 88 | .into_any(), |
| 89 | } |
| 90 | }} |
| 91 | <CtxMenuView |
| 92 | ctx=ctx |
| 93 | set_ctx=set_ctx |
| 94 | loc=loc |
| 95 | refresh=fetch |
| 96 | /> |
| 97 | </div> |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // --------------------------------------------------------------------------- |
| 102 | // Root picker (shown when the user has multiple roots and is at the top level) |
| 103 | // --------------------------------------------------------------------------- |
| 104 | |
| 105 | fn root_picker( |
| 106 | roots: &[RootInfo], |
| 107 | set_ctx: &WriteSignal<Option<CtxMenu>>, |
| 108 | loc: &Location, |
| 109 | ) -> impl IntoView { |
| 110 | view! { |
| 111 | <div class="crumbs"> |
| 112 | <span class="crumb current">"Folders"</span> |
| 113 | </div> |
| 114 | <div class="root-picker"> |
| 115 | {roots.iter().map(|r| { |
| 116 | let id = r.id; |
| 117 | let name = r.name.clone(); |
| 118 | let path = r.path.clone(); |
| 119 | let mode = r.mode.clone(); |
| 120 | let set_ctx = *set_ctx; |
| 121 | let _loc = loc.clone(); |
| 122 | view! { |
| 123 | <div |
| 124 | class="root-tile" |
| 125 | on:click=move |_| { |
| 126 | navigate(&Location { |
| 127 | root_id: Some(id), |
| 128 | path: vec![], |
| 129 | }); |
| 130 | } |
| 131 | on:contextmenu=move |ev: MouseEvent| { |
| 132 | ev.prevent_default(); |
| 133 | set_ctx.set(Some(CtxMenu { |
| 134 | x: ev.client_x() as i32, |
| 135 | y: ev.client_y() as i32, |
| 136 | entry: None, |
| 137 | })); |
| 138 | } |
| 139 | > |
| 140 | <Icon name=IconName::Folder class=Some("icon-big".into())/> |
| 141 | <div class="root-tile-name">{name}</div> |
| 142 | <div class="root-tile-path muted"> |
| 143 | {if path == "." { "/".to_string() } else { path.clone() }} |
| 144 | </div> |
| 145 | <span class="badge"> |
| 146 | {if mode == "rw" { "read-write" } else { "read-only" }} |
| 147 | </span> |
| 148 | </div> |
| 149 | } |
| 150 | }).collect::<Vec<_>>()} |
| 151 | </div> |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // --------------------------------------------------------------------------- |
| 156 | // File browser (breadcrumbs + toolbar + entries) |
| 157 | // --------------------------------------------------------------------------- |
| 158 | |
| 159 | fn file_browser( |
| 160 | root: &RootInfo, |
| 161 | loc: &ReadSignal<Location>, |
| 162 | list_state: &ReadSignal<ListState>, |
| 163 | view_mode: &ReadSignal<ViewMode>, |
| 164 | set_view_mode: &WriteSignal<ViewMode>, |
| 165 | set_ctx: &WriteSignal<Option<CtxMenu>>, |
| 166 | toast: &ToastMsg, |
| 167 | fetch: &Callback<()>, |
| 168 | ) -> impl IntoView { |
| 169 | let root_id = root.id; |
| 170 | let root_name = root.name.clone(); |
| 171 | let loc_now = loc.get(); |
| 172 | |
| 173 | // Breadcrumbs |
| 174 | let crumbs = view! { |
| 175 | <div class="crumbs"> |
| 176 | <span |
| 177 | class="crumb" |
| 178 | on:click=move |_| { |
| 179 | navigate(&Location { |
| 180 | root_id: Some(root_id), |
| 181 | path: vec![], |
| 182 | }); |
| 183 | } |
| 184 | > |
| 185 | {root_name.clone()} |
| 186 | </span> |
| 187 | {loc_now.path.iter().enumerate().map(|(i, seg)| { |
| 188 | let seg = seg.clone(); |
| 189 | let target: Vec<String> = loc_now.path[..=i].to_vec(); |
| 190 | let is_last = i == loc_now.path.len() - 1; |
| 191 | view! { |
| 192 | <span class="crumb-sep">"/"</span> |
| 193 | <span |
| 194 | class=move || { |
| 195 | if is_last { |
| 196 | "crumb current".to_string() |
| 197 | } else { |
| 198 | "crumb".to_string() |
| 199 | } |
| 200 | } |
| 201 | on:click=move |_| { |
| 202 | navigate(&Location { |
| 203 | root_id: Some(root_id), |
| 204 | path: target.clone(), |
| 205 | }); |
| 206 | } |
| 207 | > |
| 208 | {seg} |
| 209 | </span> |
| 210 | } |
| 211 | }).collect::<Vec<_>>()} |
| 212 | </div> |
| 213 | }; |
| 214 | |
| 215 | // Toolbar (view toggle) |
| 216 | let set_view = *set_view_mode; |
| 217 | let vm = *view_mode; |
| 218 | let toolbar = view! { |
| 219 | <div class="toolbar"> |
| 220 | <button |
| 221 | class=move || { |
| 222 | if vm.get() == ViewMode::Grid { |
| 223 | "icon-btn active".to_string() |
| 224 | } else { |
| 225 | "icon-btn".to_string() |
| 226 | } |
| 227 | } |
| 228 | title="Grid view" |
| 229 | on:click=move |_| { |
| 230 | set_view.set(ViewMode::Grid); |
| 231 | ViewMode::Grid.save(); |
| 232 | } |
| 233 | > |
| 234 | <Icon name=IconName::Grid class=Some("ic-btn".into())/> |
| 235 | </button> |
| 236 | <button |
| 237 | class=move || { |
| 238 | if vm.get() == ViewMode::List { |
| 239 | "icon-btn active".to_string() |
| 240 | } else { |
| 241 | "icon-btn".to_string() |
| 242 | } |
| 243 | } |
| 244 | title="List view" |
| 245 | on:click=move |_| { |
| 246 | set_view.set(ViewMode::List); |
| 247 | ViewMode::List.save(); |
| 248 | } |
| 249 | > |
| 250 | <Icon name=IconName::List class=Some("ic-btn".into())/> |
| 251 | </button> |
| 252 | </div> |
| 253 | }; |
| 254 | |
| 255 | let ls = *list_state; |
| 256 | let loc2 = *loc; |
| 257 | let set_ctx2 = *set_ctx; |
| 258 | let t = *toast; |
| 259 | let refresh = *fetch; |
| 260 | let entries_area = view! { |
| 261 | <div |
| 262 | class="entries-area" |
| 263 | on:contextmenu=move |ev: MouseEvent| { |
| 264 | ev.prevent_default(); |
| 265 | set_ctx2.set(Some(CtxMenu { |
| 266 | x: ev.client_x() as i32, |
| 267 | y: ev.client_y() as i32, |
| 268 | entry: None, |
| 269 | })); |
| 270 | } |
| 271 | > |
| 272 | {move || { |
| 273 | match ls.get() { |
| 274 | ListState::Loading => view! { |
| 275 | <p class="muted center-note">"Loading…"</p> |
| 276 | } |
| 277 | .into_view() |
| 278 | .into_any(), |
| 279 | ListState::Error(msg) => view! { |
| 280 | <div class="card error-card"> |
| 281 | <span>{msg.clone()}</span> |
| 282 | <button class="btn" on:click=move |_| refresh.run(())> |
| 283 | "Retry" |
| 284 | </button> |
| 285 | </div> |
| 286 | } |
| 287 | .into_view() |
| 288 | .into_any(), |
| 289 | ListState::Entries(entries) => { |
| 290 | if entries.is_empty() { |
| 291 | view! { <p class="muted center-note">"This folder is empty."</p> } |
| 292 | .into_view() |
| 293 | .into_any() |
| 294 | } else if vm.get() == ViewMode::Grid { |
| 295 | grid_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any() |
| 296 | } else { |
| 297 | list_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any() |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | }} |
| 302 | </div> |
| 303 | }; |
| 304 | |
| 305 | view! { |
| 306 | {crumbs} |
| 307 | {toolbar} |
| 308 | {entries_area} |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | fn entry_actions( |
| 313 | entry: Entry, |
| 314 | root_id: i64, |
| 315 | loc: &ReadSignal<Location>, |
| 316 | set_ctx: &WriteSignal<Option<CtxMenu>>, |
| 317 | _toast: &ToastMsg, |
| 318 | ) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) { |
| 319 | let l = *loc; |
| 320 | let set_ctx = *set_ctx; |
| 321 | let entry2 = entry.clone(); |
| 322 | let nav = move || { |
| 323 | let mut path = l.get().path; |
| 324 | path.push(entry.name.clone()); |
| 325 | navigate(&Location { |
| 326 | root_id: Some(root_id), |
| 327 | path, |
| 328 | }); |
| 329 | }; |
| 330 | let ctx_handler = move |ev: MouseEvent| { |
| 331 | ev.prevent_default(); |
| 332 | ev.stop_propagation(); |
| 333 | set_ctx.set(Some(CtxMenu { |
| 334 | x: ev.client_x() as i32, |
| 335 | y: ev.client_y() as i32, |
| 336 | entry: Some(entry2.clone()), |
| 337 | })); |
| 338 | }; |
| 339 | (nav, ctx_handler) |
| 340 | } |
| 341 | |
| 342 | fn grid_view( |
| 343 | entries: &[Entry], |
| 344 | root_id: i64, |
| 345 | loc: &ReadSignal<Location>, |
| 346 | set_ctx: &WriteSignal<Option<CtxMenu>>, |
| 347 | toast: &ToastMsg, |
| 348 | ) -> impl IntoView { |
| 349 | view! { |
| 350 | <div class="entries-grid"> |
| 351 | {entries.iter().map(|e| { |
| 352 | let icon = icon_for(&e.name, e.is_dir); |
| 353 | let name = e.name.clone(); |
| 354 | let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast); |
| 355 | let t = *toast; |
| 356 | let is_dir = e.is_dir; |
| 357 | let title = name.clone(); |
| 358 | let on_click = move |_| { |
| 359 | if is_dir { |
| 360 | nav(); |
| 361 | } else { |
| 362 | show(t, "Previews are coming soon"); |
| 363 | } |
| 364 | }; |
| 365 | view! { |
| 366 | <div |
| 367 | class="tile" |
| 368 | on:click=on_click |
| 369 | on:contextmenu=on_ctx |
| 370 | title=title |
| 371 | > |
| 372 | <Icon name=icon class=Some("tile-icon".into())/> |
| 373 | <div class="tile-name">{name}</div> |
| 374 | </div> |
| 375 | } |
| 376 | }).collect::<Vec<_>>()} |
| 377 | </div> |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | fn list_view( |
| 382 | entries: &[Entry], |
| 383 | root_id: i64, |
| 384 | loc: &ReadSignal<Location>, |
| 385 | set_ctx: &WriteSignal<Option<CtxMenu>>, |
| 386 | toast: &ToastMsg, |
| 387 | ) -> impl IntoView { |
| 388 | view! { |
| 389 | <div class="entries-list"> |
| 390 | {entries.iter().map(|e| { |
| 391 | let icon = icon_for(&e.name, e.is_dir); |
| 392 | let name = e.name.clone(); |
| 393 | let size = format_size(e.size); |
| 394 | let date = format_date(&e.mtime); |
| 395 | let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast); |
| 396 | let t = *toast; |
| 397 | let is_dir = e.is_dir; |
| 398 | let title = name.clone(); |
| 399 | let on_click = move |_| { |
| 400 | if is_dir { |
| 401 | nav(); |
| 402 | } else { |
| 403 | show(t, "Previews are coming soon"); |
| 404 | } |
| 405 | }; |
| 406 | view! { |
| 407 | <div class="row" on:click=on_click on:contextmenu=on_ctx title=title> |
| 408 | <Icon name=icon class=Some("row-icon".into())/> |
| 409 | <span class="row-name">{name}</span> |
| 410 | <span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span> |
| 411 | <span class="row-date muted">{date}</span> |
| 412 | </div> |
| 413 | } |
| 414 | }).collect::<Vec<_>>()} |
| 415 | </div> |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | // --------------------------------------------------------------------------- |
| 420 | // Context menu (M2 shell: only "Open" and "Refresh" work yet) |
| 421 | // --------------------------------------------------------------------------- |
| 422 | |
| 423 | #[component] |
| 424 | fn CtxMenuView( |
| 425 | ctx: ReadSignal<Option<CtxMenu>>, |
| 426 | set_ctx: WriteSignal<Option<CtxMenu>>, |
| 427 | loc: ReadSignal<Location>, |
| 428 | refresh: Callback<()>, |
| 429 | ) -> impl IntoView { |
| 430 | // Close on any click or Escape. |
| 431 | { |
| 432 | let set_ctx = set_ctx; |
| 433 | let _h1 = window_event_listener(leptos::ev::click, move |_| { |
| 434 | set_ctx.set(None); |
| 435 | }); |
| 436 | let set_ctx2 = set_ctx; |
| 437 | let _h2 = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| { |
| 438 | if ev.key() == "Escape" { |
| 439 | set_ctx2.set(None); |
| 440 | } |
| 441 | }); |
| 442 | } |
| 443 | |
| 444 | view! { |
| 445 | {move || { |
| 446 | let Some(m) = ctx.get() else { |
| 447 | return view! {}.into_any(); |
| 448 | }; |
| 449 | // Keep the menu inside the viewport. |
| 450 | let (max_x, max_y) = web_sys::window() |
| 451 | .and_then(|w| { |
| 452 | w.inner_width() |
| 453 | .ok() |
| 454 | .and_then(|v| v.as_f64()) |
| 455 | .zip(w.inner_height().ok().and_then(|v| v.as_f64())) |
| 456 | }) |
| 457 | .map(|(x, y)| (x as i32 - 220, y as i32 - 300)) |
| 458 | .unwrap_or((m.x, m.y)); |
| 459 | let x = m.x.min(max_x).max(0); |
| 460 | let y = m.y.min(max_y).max(0); |
| 461 | |
| 462 | let items: Vec<(&'static str, Option<Callback<()>>)> = match &m.entry { |
| 463 | Some(e) => { |
| 464 | let mut v: Vec<(&'static str, Option<Callback<()>>)> = vec![]; |
| 465 | if e.is_dir { |
| 466 | let name = e.name.clone(); |
| 467 | let l = loc; |
| 468 | v.push(( |
| 469 | "Open", |
| 470 | Some(Callback::new(move |_| { |
| 471 | let cur = l.get(); |
| 472 | let mut path = cur.path; |
| 473 | path.push(name.clone()); |
| 474 | // Open relative to the same root. |
| 475 | navigate(&Location { |
| 476 | root_id: cur.root_id, |
| 477 | path, |
| 478 | }); |
| 479 | })), |
| 480 | )); |
| 481 | } |
| 482 | for label in [ |
| 483 | "Download", "Rename", "Move", "Copy", "Share", "Info", "Delete", |
| 484 | ] { |
| 485 | v.push((label, None)); |
| 486 | } |
| 487 | v |
| 488 | } |
| 489 | None => vec![ |
| 490 | ("New folder", None), |
| 491 | ("Upload", None), |
| 492 | ("Refresh", Some(Callback::new(move |_| refresh.run(())))), |
| 493 | ], |
| 494 | }; |
| 495 | |
| 496 | view! { |
| 497 | <div class="ctx-menu" style=format!("left:{x}px;top:{y}px")> |
| 498 | {items.into_iter().map(|(label, action)| { |
| 499 | view! { |
| 500 | <CtxItem label=label.to_string() action=action/> |
| 501 | } |
| 502 | }).collect::<Vec<_>>()} |
| 503 | </div> |
| 504 | } |
| 505 | .into_view() |
| 506 | .into_any() |
| 507 | }} |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | #[component] |
| 512 | fn CtxItem(label: String, action: Option<Callback<()>>) -> impl IntoView { |
| 513 | let enabled = action.is_some(); |
| 514 | view! { |
| 515 | <div |
| 516 | class=move || { |
| 517 | if enabled { |
| 518 | "ctx-item".to_string() |
| 519 | } else { |
| 520 | "ctx-item disabled".to_string() |
| 521 | } |
| 522 | } |
| 523 | title=move || if enabled { String::new() } else { "Coming soon".to_string() } |
| 524 | on:click=move |_| { |
| 525 | if let Some(a) = &action { |
| 526 | a.run(()); |
| 527 | } |
| 528 | } |
| 529 | > |
| 530 | {label} |
| 531 | </div> |
| 532 | } |
| 533 | } |
| 534 |