/ web / src / pages / panels / filesystem_panel.rs
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  }