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