browser.rs
⎇
Raw
1//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
2
3use leptos::prelude::*;
4use wasm_bindgen_futures::spawn_local;
5use web_sys::MouseEvent;
6
7use crate::api::{self, Entry, Me, RootInfo};
8use crate::components::icon::{icon_for, Icon, IconName};
9use crate::components::toast::{show, ToastMsg};
10use crate::router::{navigate, Location};
11use crate::util::{format_date, format_size, ViewMode};
12
13#[derive(Clone, Debug, PartialEq)]
14enum 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.
22fn 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)]
31struct CtxMenu {
32 x: i32,
33 y: i32,
34 /// None = empty area / root tile (no entry selected).
35 entry: Option<Entry>,
36}
37
38#[component]
39pub 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
105fn 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
159fn 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
312fn 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
342fn 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
381fn 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]
424fn 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]
512fn 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