util.rs
⎇
Raw
1//! Formatting + small browser helpers.
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen::closure::Closure;
6
7/// A window event listener that is removed when the current owner is disposed.
8///
9/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
10/// `Drop` impl — if the owning component is unmounted without an explicit
11/// `remove()`, the listener keeps firing on `window` and panics by reading
12/// the component's now-disposed signals.
13pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
14 event: E,
15 cb: impl Fn(E::EventType) + 'static,
16) where
17 E::EventType: wasm_bindgen::JsCast,
18{
19 let handle = window_event_listener(event, cb);
20 on_cleanup(move || handle.remove());
21}
22
23/// Same as [`owned_window_listener`], but registered in the capture phase,
24/// so it runs before the bubble-phase window listeners (registration order
25/// otherwise decides, and that depends on component creation order).
26pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
27 event: E,
28 cb: impl Fn(E::EventType) + 'static,
29) where
30 E::EventType: wasm_bindgen::JsCast,
31{
32 let closure = Closure::<dyn FnMut(web_sys::Event)>::new({
33 move |e: web_sys::Event| cb(e.unchecked_into::<E::EventType>())
34 });
35 let add_options = web_sys::AddEventListenerOptions::new();
36 add_options.set_capture(true);
37 let remove_options = web_sys::EventListenerOptions::new();
38 remove_options.set_capture(true);
39 let name = event.name();
40 web_sys::window()
41 .expect("window")
42 .add_event_listener_with_callback_and_add_event_listener_options(
43 &name,
44 closure.as_ref().unchecked_ref(),
45 &add_options,
46 )
47 .expect("failed to add window event listener");
48 let closure_js = closure.into_js_value();
49 on_cleanup(move || {
50 let closure: js_sys::Function = closure_js.unchecked_into();
51 let _ = web_sys::window()
52 .expect("window")
53 .remove_event_listener_with_callback_and_event_listener_options(
54 &name,
55 &closure,
56 &remove_options,
57 );
58 });
59}
60
61/// The value of a `<select>` behind an event, when the target is one.
62pub fn select_value(ev: &web_sys::Event) -> Option<String> {
63 ev.target()
64 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
65 .map(|s| s.value())
66}
67
68pub fn format_size(bytes: u64) -> String {
69 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
70 let mut v = bytes as f64;
71 let mut i = 0;
72 while v >= 1024.0 && i < UNITS.len() - 1 {
73 v /= 1024.0;
74 i += 1;
75 }
76 if i == 0 {
77 format!("{bytes} B")
78 } else {
79 format!("{v:.1} {}", UNITS[i])
80 }
81}
82
83/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
84/// Falls back to the raw string when it does not look like one.
85///
86/// Every timestamp the server sends is UTC with a `Z` suffix, so the display
87/// text is a slice of the input. No date parsing is needed.
88pub fn format_date(rfc3339: &str) -> String {
89 let b = rfc3339.as_bytes();
90 if b.len() >= 16 && b[10] == b'T' {
91 return format!("{} {}", &rfc3339[..10], &rfc3339[11..16]);
92 }
93 rfc3339.to_string()
94}
95
96pub fn storage_get(key: &str) -> Option<String> {
97 let w = web_sys::window()?;
98 let storage_opt = w.local_storage().ok()?;
99 let storage = storage_opt.as_ref()?;
100 storage.get_item(key).ok().flatten()
101}
102
103pub fn storage_set(key: &str, value: &str) {
104 if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
105 let _ = s.set_item(key, value);
106 }
107}
108
109#[derive(Clone, Copy, PartialEq, Debug)]
110pub enum ViewMode {
111 Grid,
112 List,
113}
114
115impl ViewMode {
116 const KEY: &'static str = "fbng.view";
117
118 pub fn load() -> Self {
119 match storage_get(Self::KEY).as_deref() {
120 Some("list") => ViewMode::List,
121 _ => ViewMode::Grid,
122 }
123 }
124
125 pub fn save(self) {
126 storage_set(
127 Self::KEY,
128 if self == ViewMode::List {
129 "list"
130 } else {
131 "grid"
132 },
133 );
134 }
135}
136
137/// What a listing is ordered by. Folders always sort before files, so a
138/// size or date order does not scatter them through the listing.
139#[derive(Clone, Copy, PartialEq, Eq, Debug)]
140pub enum SortKey {
141 Name,
142 Size,
143 Modified,
144}
145
146impl SortKey {
147 pub fn as_str(self) -> &'static str {
148 match self {
149 SortKey::Name => "name",
150 SortKey::Size => "size",
151 SortKey::Modified => "modified",
152 }
153 }
154
155 pub fn parse(s: &str) -> Option<Self> {
156 match s {
157 "name" => Some(SortKey::Name),
158 "size" => Some(SortKey::Size),
159 "modified" => Some(SortKey::Modified),
160 _ => None,
161 }
162 }
163}
164
165/// A sort key plus its direction, persisted like [`ViewMode`].
166#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167pub struct SortSpec {
168 pub key: SortKey,
169 pub asc: bool,
170}
171
172impl Default for SortSpec {
173 fn default() -> Self {
174 // The server's own order, so the first paint after a fetch does not
175 // reshuffle.
176 Self {
177 key: SortKey::Name,
178 asc: true,
179 }
180 }
181}
182
183impl SortSpec {
184 const KEY: &'static str = "fbng.sort";
185
186 pub fn load() -> Self {
187 let raw = storage_get(Self::KEY).unwrap_or_default();
188 let (key, dir) = raw.split_once(':').unwrap_or(("", ""));
189 match SortKey::parse(key) {
190 Some(key) => Self {
191 key,
192 asc: dir != "desc",
193 },
194 None => Self::default(),
195 }
196 }
197
198 pub fn save(self) {
199 let dir = if self.asc { "asc" } else { "desc" };
200 storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
201 }
202
203 /// Order `entries` in place.
204 ///
205 /// Name is compared case-insensitively, with the raw name as the
206 /// tie-breaker so two names differing only in case keep a stable order.
207 /// Folders have no meaningful size, so they fall back to the name order
208 /// among themselves.
209 pub fn apply(self, entries: &mut [crate::api::Entry]) {
210 match self.key {
211 SortKey::Name => entries.sort_by(|a, b| {
212 a.is_dir
213 .cmp(&b.is_dir)
214 .reverse()
215 .then_with(|| cmp_name(a, b).dir(self.asc))
216 }),
217 SortKey::Size => entries.sort_by(|a, b| {
218 a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
219 if a.is_dir {
220 cmp_name(a, b)
221 } else {
222 a.size
223 .cmp(&b.size)
224 .dir(self.asc)
225 .then_with(|| cmp_name(a, b))
226 }
227 })
228 }),
229 SortKey::Modified => entries.sort_by(|a, b| {
230 a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
231 // RFC 3339 UTC with a fixed width, so bytewise order is
232 // chronological order.
233 a.mtime
234 .cmp(&b.mtime)
235 .dir(self.asc)
236 .then_with(|| cmp_name(a, b))
237 })
238 }),
239 }
240 }
241}
242
243fn cmp_name(a: &crate::api::Entry, b: &crate::api::Entry) -> std::cmp::Ordering {
244 a.name
245 .to_lowercase()
246 .cmp(&b.name.to_lowercase())
247 .then_with(|| a.name.cmp(&b.name))
248}
249
250trait OrderingDir {
251 fn dir(self, asc: bool) -> std::cmp::Ordering;
252}
253
254impl OrderingDir for std::cmp::Ordering {
255 fn dir(self, asc: bool) -> std::cmp::Ordering {
256 if asc { self } else { self.reverse() }
257 }
258}
259
260/// Entries rendered at once in a listing, and how many more each click of
261/// the "show more" row reveals. Rendering a row costs a fixed amount of
262/// reactive-graph setup, so the first paint stays well under a frame and the
263/// user pays for more rows only by asking for them.
264pub const FILE_PAGE: usize = 100;
265
266/// Footer under a paged list: where the user is in the list, and a button
267/// to reveal the next page.
268///
269/// `shown` of `total` is stated plainly rather than only counting what is
270/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
271/// "5927 more…" does not. The button appears only while more items are
272/// actually retained; past the caller's retain cap the count is all that is
273/// left to say, so the footer becomes a plain line.
274///
275/// Returns nothing at all when everything is on screen.
276pub fn page_footer(
277 shown: usize,
278 retained: usize,
279 total: usize,
280 page: usize,
281 reveal: impl Fn() + 'static,
282) -> AnyView {
283 if total <= shown {
284 return view! {}.into_view().into_any();
285 }
286 let label = crate::i18n::t_fmt2(
287 crate::i18n::k::SEARCH_SHOWN_OF,
288 &shown.to_string(),
289 &total.to_string(),
290 );
291 let pageable = retained.saturating_sub(shown);
292 if pageable == 0 {
293 return view! { <div class="page-foot">{label}</div> }
294 .into_view()
295 .into_any();
296 }
297 let next = pageable.min(page);
298 view! {
299 <div class="page-foot">
300 <span class="pf-count">{label}</span>
301 <button class="pf-more" on:click=move |_| reveal()>
302 {crate::i18n::t_fmt(crate::i18n::k::SEARCH_SHOW_MORE, &next.to_string())}
303 </button>
304 </div>
305 }
306 .into_view()
307 .into_any()
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> crate::api::Entry {
315 crate::api::Entry {
316 name: name.to_string(),
317 is_dir,
318 size,
319 mtime: mtime.to_string(),
320 kind: if is_dir {
321 api_types::FileKind::Dir
322 } else {
323 api_types::FileKind::Binary
324 },
325 }
326 }
327
328 /// This order is what the browser's arrow keys and Shift+click ranges
329 /// walk, so a wrong order moves the wrong file.
330 #[test]
331 fn sort_keeps_folders_first_in_every_order() {
332 let mut v = vec![
333 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
334 entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
335 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
336 ];
337 for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
338 for asc in [true, false] {
339 SortSpec { key, asc }.apply(&mut v);
340 assert!(v[0].is_dir, "{key:?} asc={asc} put a file first");
341 }
342 }
343 }
344
345 #[test]
346 fn sort_orders_by_the_chosen_key() {
347 let mut v = vec![
348 entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
349 entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
350 entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
351 ];
352 let names = |v: &[crate::api::Entry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
353
354 SortSpec {
355 key: SortKey::Name,
356 asc: true,
357 }
358 .apply(&mut v);
359 assert_eq!(names(&v), ["a.txt", "b.txt", "c.txt"]);
360 SortSpec {
361 key: SortKey::Name,
362 asc: false,
363 }
364 .apply(&mut v);
365 assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
366
367 SortSpec {
368 key: SortKey::Size,
369 asc: true,
370 }
371 .apply(&mut v);
372 assert_eq!(names(&v), ["b.txt", "c.txt", "a.txt"]);
373 SortSpec {
374 key: SortKey::Modified,
375 asc: true,
376 }
377 .apply(&mut v);
378 assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
379 }
380
381 #[test]
382 fn sort_spec_round_trips_through_its_stored_form() {
383 // No localStorage in the test runner, so only the parse half runs
384 // here. `load` falls back to the default.
385 assert_eq!(SortKey::parse("size"), Some(SortKey::Size));
386 assert_eq!(SortKey::parse("bogus"), None);
387 assert_eq!(SortSpec::default().key, SortKey::Name);
388 assert!(SortSpec::default().asc);
389 }
390
391 #[test]
392 fn format_date_drops_seconds_and_zone() {
393 assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
394 assert_eq!(format_date("nonsense"), "nonsense");
395 }
396}
397