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 }