filesystem_panel.rs
1 use super::file_icon; 2 use crate::api; 3 use crate::api::{DeviceStatusData, FileEntry}; 4 use crate::services::FileService; 5 use dioxus::html::HasFileData; 6 use dioxus::prelude::*; 7 use dioxus_primitives::alert_dialog::{ 8 AlertDialogAction, AlertDialogActions, AlertDialogCancel, AlertDialogContent, 9 AlertDialogDescription, AlertDialogRoot, AlertDialogTitle, 10 }; 11 use dioxus_primitives::dialog::{DialogContent, DialogRoot}; 12 use lucide_dioxus::{Download, HardDrive, Pencil, Plus, Trash2, X}; 13 use ui::components::button::{Button, ButtonSize, ButtonVariant}; 14 use ui::components::input::Input; 15 use ui::components::label::Label; 16 use ui::components::progress::{Progress, ProgressVariant}; 17 use ui::components::toast::use_toast; 18 19 #[component] 20 pub fn FilesystemPanel( 21 device_url: Signal<String>, 22 files: Signal<Vec<FileEntry>>, 23 littlefs_files: Signal<Vec<FileEntry>>, 24 littlefs_total_bytes: Signal<u64>, 25 littlefs_used_bytes: Signal<u64>, 26 status: Signal<Option<DeviceStatusData>>, 27 storage_percent: Memo<f64>, 28 ) -> Element { 29 let toasts = use_toast(); 30 let sd_upload_input_label = use_signal(|| Some("sd-upload-input".to_string())); 31 let mut pending_delete: Signal<Option<(String, String)>> = use_signal(|| None); 32 let mut pending_rename: Signal<Option<(String, String)>> = use_signal(|| None); 33 let mut rename_input = use_signal(String::new); 34 let mut preview_name: Signal<Option<String>> = use_signal(|| None); 35 let mut preview_rows: Signal<Vec<Vec<String>>> = use_signal(Vec::new); 36 37 let upload_to_sd = move |name: String, bytes: Vec<u8>| { 38 let url = device_url.read().clone(); 39 toasts.info(format!("Uploading {name}..."), None); 40 spawn(async move { 41 match FileService::upload(&url, "sd", &name, &bytes).await { 42 Ok(resp) if resp.status().is_success() => { 43 toasts.success(format!("Uploaded {name}"), None); 44 if let Ok(entries) = FileService::list(&url, "sd").await { 45 files.set(entries); 46 } 47 } 48 _ => toasts.error(format!("Upload failed: {name}"), None), 49 } 50 }); 51 }; 52 53 let open_preview = move |location: &'static str, name: String| { 54 let url = device_url.read().clone(); 55 spawn(async move { 56 match FileService::read_text(&url, location, &name).await { 57 Ok(text) => { 58 let rows: Vec<Vec<String>> = text 59 .lines() 60 .take(200) 61 .map(|line| line.split(',').map(|c| c.trim().to_string()).collect()) 62 .collect(); 63 preview_rows.set(rows); 64 preview_name.set(Some(name)); 65 } 66 Err(_) => toasts.error("Failed to fetch file".to_string(), None), 67 } 68 }); 69 }; 70 71 let status_data = status.read(); 72 73 let littlefs_percent = use_memo(move || { 74 let total = *littlefs_total_bytes.read(); 75 if total > 0 { 76 (*littlefs_used_bytes.read() as f64 / total as f64 * 100.0).clamp(0.0, 100.0) 77 } else { 78 0.0 79 } 80 }); 81 82 rsx! { 83 section { id: "filesystem-section", class: "panel-shell-strong p-4", 84 h2 { class: "mb-3 text-xl font-semibold", "Filesystem" } 85 86 // SD Card 87 div { 88 class: "border border-border rounded-lg overflow-hidden p-3 transition-colors", 89 ondragover: move |e| { e.prevent_default(); }, 90 ondrop: move |e| async move { 91 e.prevent_default(); 92 for file in e.files() { 93 let name = file.name(); 94 match file.read_bytes().await { 95 Ok(bytes) => upload_to_sd(name, bytes.to_vec()), 96 Err(_) => toasts.error(format!("Failed to read {}", file.name()), None), 97 } 98 } 99 }, 100 div { class: "flex items-center gap-2 mb-1", 101 HardDrive { class: "w-5 h-5 text-primary" } 102 span { class: "font-semibold", "SD" } 103 if let Some(ref device_status) = *status_data { 104 span { class: "text-xs text-muted-foreground ml-auto", 105 "{api::format_storage_pair(device_status.storage.used_bytes, device_status.storage.total_bytes)}" 106 } 107 } 108 } 109 110 if status_data.is_some() { 111 div { class: "mb-3", 112 Progress { 113 value: storage_percent, 114 max: 100.0, 115 variant: if storage_percent() > 90.0 { ProgressVariant::Destructive } else if storage_percent() > 70.0 { ProgressVariant::Warning } else { ProgressVariant::Success }, 116 } 117 } 118 } 119 120 if files.read().is_empty() && status_data.is_none() { 121 for _ in 0..3 { 122 div { class: "flex items-center gap-2 py-2", 123 div { class: "w-4 h-4 bg-muted rounded animate-pulse" } 124 div { class: "h-4 flex-1 bg-muted rounded animate-pulse" } 125 div { class: "h-4 w-14 bg-muted rounded animate-pulse" } 126 } 127 } 128 } 129 130 for file in files.read().iter() { 131 { file_row(file, "sd", &device_url, open_preview, &mut pending_rename, &mut rename_input, &mut pending_delete) } 132 } 133 134 Label { 135 for_id: sd_upload_input_label, 136 class: Some("mt-2 mb-0 w-full py-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:bg-muted/30 transition-colors flex items-center justify-center gap-1 cursor-pointer".to_string()), 137 Plus { class: "w-3.5 h-3.5" } 138 "Add file..." 139 } 140 } 141 142 // LittleFS 143 div { class: "mt-3 border border-border rounded-lg overflow-hidden p-3", 144 div { class: "flex items-center gap-2 mb-1", 145 HardDrive { class: "w-5 h-5 text-primary" } 146 span { class: "font-semibold", "LittleFS" } 147 if *littlefs_total_bytes.read() > 0 { 148 span { class: "text-xs text-muted-foreground ml-auto", 149 "{api::format_storage_pair(*littlefs_used_bytes.read(), *littlefs_total_bytes.read())}" 150 } 151 } 152 } 153 154 if *littlefs_total_bytes.read() > 0 { 155 { 156 let pct = *littlefs_percent.read(); 157 let variant = if pct > 90.0 { ProgressVariant::Destructive } else if pct > 70.0 { ProgressVariant::Warning } else { ProgressVariant::Success }; 158 rsx! { 159 div { class: "mb-3", 160 Progress { 161 value: littlefs_percent, 162 max: 100.0, 163 variant: variant, 164 } 165 } 166 } 167 } 168 } 169 170 if littlefs_files.read().is_empty() && status_data.is_none() { 171 for _ in 0..2 { 172 div { class: "flex items-center gap-2 py-2", 173 div { class: "w-4 h-4 bg-muted rounded animate-pulse" } 174 div { class: "h-4 flex-1 bg-muted rounded animate-pulse" } 175 div { class: "h-4 w-14 bg-muted rounded animate-pulse" } 176 } 177 } 178 } 179 180 for file in littlefs_files.read().iter() { 181 { file_row(file, "littlefs", &device_url, open_preview, &mut pending_rename, &mut rename_input, &mut pending_delete) } 182 } 183 184 Button { 185 class: "mt-2 w-full py-2 border-dashed text-sm text-muted-foreground hover:bg-muted/30".to_string(), 186 variant: ButtonVariant::Outline, 187 disabled: true, 188 Plus { class: "w-3.5 h-3.5" } 189 "Add file..." 190 } 191 } 192 193 input { 194 id: "sd-upload-input", 195 r#type: "file", 196 class: "hidden", 197 onchange: move |evt| async move { 198 for file in evt.files() { 199 let name = file.name(); 200 match file.read_bytes().await { 201 Ok(bytes) => upload_to_sd(name, bytes.to_vec()), 202 Err(_) => toasts.error(format!("Failed to read {}", file.name()), None), 203 } 204 } 205 }, 206 } 207 208 // Delete confirmation modal 209 { 210 let is_open = pending_delete.read().is_some(); 211 let delete_fs = pending_delete.read().as_ref().map(|(f, _)| f.clone()).unwrap_or_default(); 212 let delete_name = pending_delete.read().as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); 213 rsx! { 214 AlertDialogRoot { 215 open: is_open, 216 on_open_change: move |v: bool| { if !v { pending_delete.set(None); } }, 217 class: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm", 218 219 AlertDialogContent { 220 class: "bg-card border border-border rounded-lg shadow-2xl p-6 max-w-sm mx-4", 221 222 AlertDialogTitle { class: "text-lg font-semibold mb-2", "Delete file" } 223 AlertDialogDescription { 224 class: "text-sm text-muted-foreground mb-4", 225 "Are you sure you want to delete " 226 span { class: "font-mono text-foreground", "{delete_name}" } 227 "? This cannot be undone." 228 } 229 AlertDialogActions { 230 class: "flex justify-end gap-2", 231 AlertDialogCancel { 232 class: "px-3 py-1.5 rounded-lg border border-border text-sm hover:bg-muted/50 transition-colors", 233 "Cancel" 234 } 235 AlertDialogAction { 236 class: "px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm hover:bg-destructive/90 transition-colors", 237 on_click: move |_| { 238 let fs_type = delete_fs.clone(); 239 let name = delete_name.clone(); 240 if name.is_empty() { return; } 241 let url = device_url.read().clone(); 242 toasts.info(format!("Deleting {name}..."), None); 243 spawn(async move { 244 match FileService::delete(&url, &fs_type, &name).await { 245 Ok(response) if response.status().is_success() => { 246 toasts.success(format!("Deleted {name}"), None); 247 if fs_type == "sd" { 248 if let Ok(entries) = FileService::list(&url, "sd").await { 249 files.set(entries); 250 } 251 } else { 252 if let Ok(entries) = FileService::list(&url, "littlefs").await { 253 littlefs_files.set(entries); 254 } 255 } 256 } 257 _ => toasts.error(format!("Failed to delete {name}"), None), 258 } 259 }); 260 }, 261 "Delete" 262 } 263 } 264 } 265 } 266 } 267 } 268 269 // Rename modal 270 { 271 let is_open = pending_rename.read().is_some(); 272 let rename_fs = pending_rename.read().as_ref().map(|(f, _)| f.clone()).unwrap_or_default(); 273 let rename_old = pending_rename.read().as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); 274 rsx! { 275 AlertDialogRoot { 276 open: is_open, 277 on_open_change: move |v: bool| { if !v { pending_rename.set(None); } }, 278 class: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm", 279 280 AlertDialogContent { 281 class: "bg-card border border-border rounded-lg shadow-2xl p-6 max-w-sm mx-4", 282 283 AlertDialogTitle { class: "text-lg font-semibold mb-2", "Rename file" } 284 AlertDialogDescription { 285 class: "text-sm text-muted-foreground mb-4", 286 "Rename " 287 span { class: "font-mono text-foreground", "{rename_old}" } 288 " to:" 289 } 290 Input { 291 class: Some("w-full font-mono text-sm mb-4".to_string()), 292 input_type: "text".to_string(), 293 aria_label: Some("New filename".to_string()), 294 value: rename_input.read().clone(), 295 on_input: Some(Callback::new(move |e: FormEvent| { 296 rename_input.set(e.value()); 297 })), 298 } 299 AlertDialogActions { 300 class: "flex justify-end gap-2", 301 AlertDialogCancel { 302 class: "px-3 py-1.5 rounded-lg border border-border text-sm hover:bg-muted/50 transition-colors", 303 "Cancel" 304 } 305 AlertDialogAction { 306 class: "px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm hover:bg-destructive/90 transition-colors", 307 on_click: move |_| { 308 let fs_type = rename_fs.clone(); 309 let old = rename_old.clone(); 310 let new_name = rename_input.read().trim().to_string(); 311 if new_name.is_empty() || new_name == old { 312 return; 313 } 314 let url = device_url.read().clone(); 315 toasts.info(format!("Renaming {old} to {new_name}..."), None); 316 spawn(async move { 317 match FileService::rename(&url, &fs_type, &old, &new_name).await { 318 Ok(response) if response.status().is_success() => { 319 toasts.success(format!("Renamed to {new_name}"), None); 320 if fs_type == "sd" { 321 if let Ok(entries) = FileService::list(&url, "sd").await { 322 files.set(entries); 323 } 324 } else { 325 if let Ok(entries) = FileService::list(&url, "littlefs").await { 326 littlefs_files.set(entries); 327 } 328 } 329 } 330 _ => toasts.error(format!("Failed to rename {old}"), None), 331 } 332 }); 333 }, 334 "Rename" 335 } 336 } 337 } 338 } 339 } 340 } 341 342 // CSV preview modal 343 { 344 let is_preview_open = preview_name.read().is_some(); 345 let preview_filename = preview_name.read().clone().unwrap_or_default(); 346 let row_count = preview_rows.read().len().saturating_sub(1); 347 rsx! { 348 DialogRoot { 349 open: is_preview_open, 350 on_open_change: move |v: bool| { if !v { preview_name.set(None); } }, 351 class: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4", 352 353 DialogContent { 354 class: "w-full max-w-3xl bg-card border border-border rounded-lg shadow-2xl flex flex-col max-h-[80vh]", 355 356 div { class: "flex items-center justify-between px-5 py-4 border-b border-border", 357 div { 358 h3 { class: "text-sm font-semibold font-mono", "{preview_filename}" } 359 p { class: "text-xs text-muted-foreground", "{row_count} rows" } 360 } 361 Button { 362 variant: ButtonVariant::Ghost, 363 size: ButtonSize::Small, 364 is_icon_button: true, 365 aria_label: "Close".to_string(), 366 on_click: move |_| preview_name.set(None), 367 X { class: "w-5 h-5" } 368 } 369 } 370 371 div { class: "flex-1 overflow-auto", 372 table { class: "w-full text-xs font-mono", 373 if let Some(header) = preview_rows.read().first() { 374 thead { 375 tr { class: "bg-muted sticky top-0", 376 for cell in header.iter() { 377 th { class: "px-3 py-2 text-left text-muted-foreground whitespace-nowrap border-b border-border", 378 "{cell}" 379 } 380 } 381 } 382 } 383 } 384 tbody { 385 for (i, row) in preview_rows.read().iter().skip(1).enumerate() { 386 tr { key: "{i}", class: if i % 2 == 0 { "" } else { "bg-muted/30" }, 387 for cell in row.iter() { 388 td { class: "px-3 py-1.5 whitespace-nowrap", "{cell}" } 389 } 390 } 391 } 392 } 393 } 394 } 395 396 div { class: "flex items-center gap-2 px-5 py-3 border-t border-border", 397 div { class: "flex-1" } 398 Button { 399 variant: ButtonVariant::Outline, 400 on_click: move |_| preview_name.set(None), 401 "Close" 402 } 403 } 404 } 405 } 406 } 407 } 408 } 409 } 410 } 411 412 fn file_row( 413 file: &FileEntry, 414 location: &'static str, 415 device_url: &Signal<String>, 416 open_preview: impl Fn(&'static str, String) + Copy + 'static, 417 pending_rename: &mut Signal<Option<(String, String)>>, 418 rename_input: &mut Signal<String>, 419 pending_delete: &mut Signal<Option<(String, String)>>, 420 ) -> Element { 421 let filename = file.name.clone(); 422 let file_size = file.size; 423 let filename_for_preview = filename.clone(); 424 let filename_for_rename = filename.clone(); 425 let filename_for_delete = filename.clone(); 426 let filename_for_download = filename.clone(); 427 let device = device_url.read().clone(); 428 let is_previewable = filename.ends_with(".csv") || filename.ends_with(".tsv"); 429 let mut pending_rename = *pending_rename; 430 let mut rename_input = *rename_input; 431 let mut pending_delete = *pending_delete; 432 433 rsx! { 434 div { key: "{filename}", class: "flex items-center gap-2 py-2 group relative", 435 {file_icon(&filename)} 436 if is_previewable { 437 span { 438 class: "text-sm font-mono text-foreground truncate flex-1 cursor-pointer hover:underline", 439 onclick: move |_| open_preview(location, filename_for_preview.clone()), 440 "{filename}" 441 } 442 } else { 443 span { class: "text-sm font-mono text-foreground truncate flex-1", "{filename}" } 444 } 445 span { class: "text-xs text-muted-foreground shrink-0 ml-auto tabular-nums transition-opacity duration-200 ease-in-out opacity-100 group-hover:opacity-0", "{api::format_file_size(file_size)}" } 446 div { class: "flex items-center gap-0.5 shrink-0 ml-auto absolute right-0 transition-opacity duration-200 ease-in-out opacity-0 group-hover:opacity-100", 447 if location == "sd" { 448 a { 449 class: "p-1 rounded hover:bg-accent/40 text-muted-foreground", 450 aria_label: "Download {filename}", 451 href: "{device}/api/filesystem/sd/{filename_for_download}", 452 target: "_blank", 453 Download { class: "w-3.5 h-3.5" } 454 } 455 } 456 Button { 457 variant: ButtonVariant::Ghost, 458 size: ButtonSize::Small, 459 is_icon_button: true, 460 class: "p-1".to_string(), 461 aria_label: format!("Rename {filename}"), 462 on_click: move |_| { 463 rename_input.set(filename_for_rename.clone()); 464 pending_rename.set(Some((location.into(), filename_for_rename.clone()))); 465 }, 466 Pencil { class: "w-3.5 h-3.5" } 467 } 468 Button { 469 variant: ButtonVariant::Destructive, 470 size: ButtonSize::Small, 471 is_icon_button: true, 472 class: "p-1".to_string(), 473 aria_label: format!("Delete {filename}"), 474 on_click: move |_| { 475 pending_delete.set(Some((location.into(), filename_for_delete.clone()))); 476 }, 477 Trash2 { class: "w-3.5 h-3.5" } 478 } 479 } 480 } 481 } 482 }