/ crates / willow-rummager / src / willow_ui.rs
willow_ui.rs
  1  use crate::gpui;
  2  use crate::workspace::ui::{
  3      ActiveTheme, Clickable, Color, Icon, IconButton, IconName, IconSize, Label, LabelCommon,
  4      LabelSize, ListItem, ListItemSpacing, h_flex, v_flex,
  5  };
  6  use crate::workspace::{Item, Workspace};
  7  use gpui::{
  8      App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, ParentElement as _,
  9      Render, SharedString, actions, div, *,
 10  };
 11  
 12  actions!(
 13      workspace,
 14      [
 15          /// Open the willow filesystem browser
 16          OpenWillowUi,
 17          /// Toggle namespace expansion
 18          ToggleNamespace,
 19          /// Toggle subspace expansion
 20          ToggleSubspace,
 21          /// Navigate to path
 22          NavigateToPath,
 23          /// Create new folder
 24          CreateFolder,
 25          /// Upload file
 26          UploadFile,
 27      ]
 28  );
 29  
 30  #[derive(Debug, Clone, PartialEq)]
 31  pub struct Entry {
 32      pub namespace_id: String,
 33      pub subspace_id: String,
 34      pub path: String,
 35      pub timestamp: i64,
 36  }
 37  
 38  #[derive(Debug, Clone)]
 39  pub struct BreadcrumbItem {
 40      pub name: String,
 41      // pub path: String,
 42  }
 43  
 44  pub fn init(cx: &mut App) {
 45      cx.observe_new(move |workspace: &mut Workspace, _window, _cx| {
 46          workspace.register_action(move |workspace, _: &OpenWillowUi, window, cx| {
 47              let willow_ui = cx.new(|cx| WillowUi::new(cx));
 48              workspace.add_item_to_active_pane(Box::new(willow_ui), None, true, window, cx)
 49          });
 50      })
 51      .detach();
 52  }
 53  
 54  pub struct WillowUi {
 55      focus_handle: FocusHandle,
 56      entries: Vec<Entry>,
 57      current_path: Vec<BreadcrumbItem>,
 58      selected_namespace: Option<String>,
 59      selected_subspace: Option<String>,
 60  }
 61  
 62  impl WillowUi {
 63      pub fn new(cx: &mut Context<Self>) -> Self {
 64          let focus_handle = cx.focus_handle();
 65  
 66          // Initialize with sample data - flattened entries
 67          let entries = vec![
 68              Entry {
 69                  namespace_id: "family".to_string(),
 70                  subspace_id: "alice".to_string(),
 71                  path: "/family/alice/Documents".to_string(),
 72                  timestamp: 1704067200, // 2 days ago (example timestamp)
 73              },
 74              Entry {
 75                  namespace_id: "family".to_string(),
 76                  subspace_id: "alice".to_string(),
 77                  path: "/family/alice/Photos".to_string(),
 78                  timestamp: 1703462400, // 1 week ago (example timestamp)
 79              },
 80              Entry {
 81                  namespace_id: "family".to_string(),
 82                  subspace_id: "bob".to_string(),
 83                  path: "/family/bob/Music".to_string(),
 84                  timestamp: 1703980800, // 3 days ago (example timestamp)
 85              },
 86              Entry {
 87                  namespace_id: "work".to_string(),
 88                  subspace_id: "projects".to_string(),
 89                  path: "/work/projects/willow-fs".to_string(),
 90                  timestamp: 1704153600, // 1 hour ago (example timestamp)
 91              },
 92              Entry {
 93                  namespace_id: "work".to_string(),
 94                  subspace_id: "projects".to_string(),
 95                  path: "/work/projects/presentation.pdf".to_string(),
 96                  timestamp: 1704067200, // Yesterday (example timestamp)
 97              },
 98              Entry {
 99                  namespace_id: "photos".to_string(),
100                  subspace_id: "vacation_2024".to_string(),
101                  path: "/photos/vacation_2024/beach.jpg".to_string(),
102                  timestamp: 1702857600, // 2 weeks ago (example timestamp)
103              },
104              Entry {
105                  namespace_id: "photos".to_string(),
106                  subspace_id: "vacation_2024".to_string(),
107                  path: "/photos/vacation_2024/mountains.jpg".to_string(),
108                  timestamp: 1702857600, // 2 weeks ago (example timestamp)
109              },
110          ];
111  
112          Self {
113              focus_handle,
114              entries,
115              current_path: vec![],
116              selected_namespace: None,
117              selected_subspace: None,
118          }
119      }
120  
121      fn toggle_namespace(&mut self, namespace_id: &str, _cx: &mut Context<Self>) {
122          if self.selected_namespace.as_ref() == Some(&namespace_id.to_string()) {
123              self.selected_namespace = None;
124              self.selected_subspace = None;
125          } else {
126              self.selected_namespace = Some(namespace_id.to_string());
127              self.selected_subspace = None;
128          }
129      }
130  
131      fn toggle_subspace(&mut self, namespace_id: &str, subspace_id: &str, _cx: &mut Context<Self>) {
132          if self.selected_namespace.as_ref() == Some(&namespace_id.to_string())
133              && self.selected_subspace.as_ref() == Some(&subspace_id.to_string())
134          {
135              self.selected_subspace = None;
136          } else {
137              self.selected_namespace = Some(namespace_id.to_string());
138              self.selected_subspace = Some(subspace_id.to_string());
139              self.current_path = vec![
140                  BreadcrumbItem {
141                      name: namespace_id.to_string(),
142                      // path: format!("/{}", namespace_id),
143                  },
144                  BreadcrumbItem {
145                      name: subspace_id.to_string(),
146                      // path: format!("/{}/{}", namespace_id, subspace_id),
147                  },
148              ];
149          }
150      }
151  
152      fn render_breadcrumbs(&self, cx: &mut Context<Self>) -> impl gpui::IntoElement {
153          h_flex()
154              .gap_2()
155              .items_center()
156              .p_2()
157              .bg(cx.theme().colors().surface_background)
158              .border_b_1()
159              .border_color(cx.theme().colors().border_variant)
160              .child(
161                  Icon::new(IconName::File)
162                      .size(IconSize::Small)
163                      .color(Color::Muted),
164              )
165              .children(self.current_path.iter().enumerate().map(|(i, item)| {
166                  let mut flex = h_flex().gap_1();
167                  if i > 0 {
168                      flex = flex.child(
169                          Icon::new(IconName::ChevronRight)
170                              .size(IconSize::Small)
171                              .color(Color::Muted),
172                      );
173                  }
174                  flex.child(Label::new(&item.name).size(LabelSize::Small).color(
175                      if i == self.current_path.len() - 1 {
176                          Color::Default
177                      } else {
178                          Color::Muted
179                      },
180                  ))
181              }))
182      }
183  
184      fn render_namespace(
185          &self,
186          namespace_id: &str,
187          namespace_idx: usize,
188          cx: &mut Context<Self>,
189      ) -> impl gpui::IntoElement {
190          let namespace_id_owned = namespace_id.to_string();
191          let is_expanded = self.selected_namespace.as_ref() == Some(&namespace_id_owned);
192  
193          let mut namespace_container = v_flex().w_full().child(
194              ListItem::new(("namespace", namespace_idx))
195                  .spacing(ListItemSpacing::Sparse)
196                  .start_slot(
197                      h_flex()
198                          .gap_2()
199                          .child(
200                              IconButton::new(
201                                  ("expand-ns", namespace_idx),
202                                  if is_expanded {
203                                      IconName::ChevronDown
204                                  } else {
205                                      IconName::ChevronRight
206                                  },
207                              )
208                              .on_click({
209                                  let namespace_id_for_callback = namespace_id_owned.clone();
210                                  cx.listener(move |this, _event, _window, cx| {
211                                      this.toggle_namespace(&namespace_id_for_callback, cx);
212                                      cx.notify();
213                                  })
214                              }),
215                          )
216                          .child(
217                              Icon::new(IconName::Person)
218                                  .size(IconSize::Small)
219                                  .color(Color::Accent),
220                          ),
221                  )
222                  .child(
223                      Label::new(&namespace_id_owned)
224                          .size(LabelSize::Default)
225                          .color(Color::Default),
226                  )
227                  .end_slot(
228                      Label::new("Namespace")
229                          .size(LabelSize::Small)
230                          .color(Color::Muted),
231                  ),
232          );
233  
234          if is_expanded {
235              let subspaces = self.get_subspaces_for_namespace(namespace_id);
236              let mut subspace_container = v_flex().ml_6();
237              for (subspace_idx, subspace_id) in subspaces.iter().enumerate() {
238                  subspace_container = subspace_container.child(self.render_subspace(
239                      namespace_id,
240                      subspace_id,
241                      subspace_idx,
242                      cx,
243                  ));
244              }
245              namespace_container = namespace_container.child(subspace_container);
246          }
247  
248          namespace_container
249      }
250  
251      fn render_subspace(
252          &self,
253          namespace_id: &str,
254          subspace_id: &str,
255          subspace_idx: usize,
256          cx: &mut Context<Self>,
257      ) -> impl gpui::IntoElement {
258          let subspace_id_owned = subspace_id.to_string();
259          let namespace_id_owned = namespace_id.to_string();
260          let is_expanded = self.selected_namespace.as_ref() == Some(&namespace_id_owned)
261              && self.selected_subspace.as_ref() == Some(&subspace_id_owned);
262  
263          let mut subspace_container = v_flex().w_full().child(
264              ListItem::new(("subspace", subspace_idx))
265                  .spacing(ListItemSpacing::Sparse)
266                  .start_slot(
267                      h_flex()
268                          .gap_2()
269                          .child(
270                              IconButton::new(
271                                  ("expand-ss", subspace_idx),
272                                  if is_expanded {
273                                      IconName::ChevronDown
274                                  } else {
275                                      IconName::ChevronRight
276                                  },
277                              )
278                              .on_click({
279                                  let namespace_id_for_callback = namespace_id_owned.clone();
280                                  let subspace_id_for_callback = subspace_id_owned.clone();
281                                  cx.listener(move |this, _event, _window, cx| {
282                                      this.toggle_subspace(
283                                          &namespace_id_for_callback,
284                                          &subspace_id_for_callback,
285                                          cx,
286                                      );
287                                      cx.notify();
288                                  })
289                              }),
290                          )
291                          .child(
292                              Icon::new(IconName::Person)
293                                  .size(IconSize::Small)
294                                  .color(Color::Accent),
295                          ),
296                  )
297                  .child(
298                      Label::new(&subspace_id_owned)
299                          .size(LabelSize::Default)
300                          .color(Color::Default),
301                  ),
302          );
303  
304          if is_expanded {
305              let entries = self.get_entries_for_subspace(namespace_id, subspace_id);
306              let mut items_container = v_flex().ml_6();
307              for (entry_idx, entry) in entries.iter().enumerate() {
308                  items_container = items_container.child(self.render_entry(entry, entry_idx, cx));
309              }
310              subspace_container = subspace_container.child(items_container);
311          }
312  
313          subspace_container
314      }
315  
316      fn render_entry(
317          &self,
318          entry: &Entry,
319          entry_idx: usize,
320          _cx: &mut Context<Self>,
321      ) -> impl gpui::IntoElement {
322          let path_parts: Vec<&str> = entry.path.split('/').collect();
323          let name = path_parts.last().unwrap_or(&"").to_string();
324          let is_directory = !name.contains('.');
325  
326          let icon = if is_directory {
327              IconName::Folder
328          } else {
329              match name.split('.').last().unwrap_or("") {
330                  "jpg" | "jpeg" | "png" | "gif" | "bmp" => IconName::Image,
331                  "pdf" => IconName::File,
332                  "mp3" | "wav" | "flac" => IconName::File,
333                  "mp4" | "avi" | "mkv" => IconName::File,
334                  _ => IconName::File,
335              }
336          };
337  
338          ListItem::new(("entry", entry_idx))
339              .spacing(ListItemSpacing::Sparse)
340              .start_slot(
341                  Icon::new(icon)
342                      .size(IconSize::Small)
343                      .color(if is_directory {
344                          Color::Accent
345                      } else {
346                          Color::Muted
347                      }),
348              )
349              .child(
350                  h_flex()
351                      .justify_between()
352                      .w_full()
353                      .child(
354                          Label::new(&name)
355                              .size(LabelSize::Default)
356                              .color(Color::Default),
357                      )
358                      .child(
359                          h_flex().gap_4().child(
360                              Label::new(&self.format_timestamp(entry.timestamp))
361                                  .size(LabelSize::Small)
362                                  .color(Color::Muted),
363                          ),
364                      ),
365              )
366      }
367  
368      fn format_timestamp(&self, timestamp: i64) -> String {
369          // Simple timestamp formatting - in a real app you'd use a proper date library
370          match timestamp {
371              1704153600 => "1 hour ago".to_string(),
372              1704067200 => "Yesterday".to_string(),
373              1703980800 => "3 days ago".to_string(),
374              1703462400 => "1 week ago".to_string(),
375              1702857600 => "2 weeks ago".to_string(),
376              _ => format!("Timestamp: {}", timestamp),
377          }
378      }
379  
380      fn get_namespaces(&self) -> Vec<String> {
381          let mut namespaces = Vec::new();
382          for entry in &self.entries {
383              if !namespaces.contains(&entry.namespace_id) {
384                  namespaces.push(entry.namespace_id.clone());
385              }
386          }
387          namespaces.sort();
388          namespaces
389      }
390  
391      fn get_subspaces_for_namespace(&self, namespace_id: &str) -> Vec<String> {
392          let mut subspaces = Vec::new();
393          for entry in &self.entries {
394              if entry.namespace_id == namespace_id && !subspaces.contains(&entry.subspace_id) {
395                  subspaces.push(entry.subspace_id.clone());
396              }
397          }
398          subspaces.sort();
399          subspaces
400      }
401  
402      fn get_entries_for_subspace(&self, namespace_id: &str, subspace_id: &str) -> Vec<&Entry> {
403          self.entries
404              .iter()
405              .filter(|entry| entry.namespace_id == namespace_id && entry.subspace_id == subspace_id)
406              .collect()
407      }
408  
409      fn render_toolbar(&self, cx: &mut Context<Self>) -> impl gpui::IntoElement {
410          h_flex()
411              .w_full()
412              .justify_between()
413              .p_2()
414              .border_b_1()
415              .border_color(cx.theme().colors().border_variant)
416              .bg(cx.theme().colors().toolbar_background)
417              .child(
418                  Label::new("Willow Filesystem")
419                      .size(LabelSize::Default)
420                      .color(Color::Default),
421              )
422              .child(
423                  h_flex()
424                      .gap_2()
425                      .child(IconButton::new("create-folder", IconName::Folder))
426                      .child(IconButton::new("upload-file", IconName::File))
427                      .child(IconButton::new("refresh", IconName::ArrowCircle)),
428              )
429      }
430  }
431  
432  pub enum WillowEvent {
433      //
434  }
435  
436  impl Item for WillowUi {
437      type Event = WillowEvent;
438  
439      fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
440          "Willow FS".into()
441      }
442  }
443  
444  impl Focusable for WillowUi {
445      fn focus_handle(&self, _cx: &App) -> FocusHandle {
446          self.focus_handle.clone()
447      }
448  }
449  
450  impl EventEmitter<WillowEvent> for WillowUi {}
451  
452  impl Render for WillowUi {
453      fn render(
454          &mut self,
455          _window: &mut gpui::Window,
456          cx: &mut gpui::Context<Self>,
457      ) -> impl gpui::IntoElement {
458          v_flex()
459              .size_full()
460              .bg(cx.theme().colors().panel_background)
461              .on_action(
462                  cx.listener(|_this, _action: &ToggleNamespace, _window, cx| {
463                      // Handle toggle namespace action
464                      cx.notify();
465                  }),
466              )
467              .on_action(cx.listener(|_this, _action: &ToggleSubspace, _window, cx| {
468                  // Handle toggle subspace action
469                  cx.notify();
470              }))
471              .child(self.render_toolbar(cx))
472              .child(self.render_breadcrumbs(cx))
473              .child(div().flex_1().overflow_hidden().p_3().child({
474                  let mut flex = v_flex().gap_1();
475                  let namespaces = self.get_namespaces();
476                  for (namespace_idx, namespace_id) in namespaces.iter().enumerate() {
477                      flex = flex.child(self.render_namespace(namespace_id, namespace_idx, cx));
478                  }
479                  flex
480              }))
481      }
482  }