willow_panel.rs
1 use crate::db::kvp::KEY_VALUE_STORE; 2 use crate::gpui; 3 use crate::util::ResultExt as _; 4 use anyhow::{Context as _, bail}; 5 use gpui::{prelude::FluentBuilder, *}; 6 use serde::{Deserialize, Serialize}; 7 8 // Import Entry from willow_ui for consistent data model 9 use crate::willow_ui::Entry; 10 11 // use willow_panel_settings::WillowPanelSettings; 12 use crate::workspace::{ 13 Panel, Workspace, 14 dock::{DockPosition, PanelEvent}, 15 ui::{ 16 ActiveTheme, Clickable, Color, Icon, IconButton, IconName, IconSize, Label, LabelCommon, 17 LabelSize, ListItem, ListItemSpacing, h_flex, v_flex, 18 }, 19 }; 20 21 actions!( 22 workspace, 23 [ 24 ToggleFocus, 25 OpenWillow, 26 CreateDocument, 27 CreateSubspace, 28 ViewDocument, 29 DeleteDocument, 30 RefreshDocuments, 31 CloseDialog, 32 SaveDocument, 33 ] 34 ); 35 36 pub fn init(cx: &mut App) { 37 cx.observe_new( 38 |workspace: &mut Workspace, window, cx: &mut Context<Workspace>| { 39 let Some(window) = window else { return }; 40 41 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { 42 workspace.toggle_panel_focus::<WillowPanel>(window, cx); 43 }); 44 45 cx.spawn_in(window, async move |workspace_handle, cx| { 46 let willow_panel = WillowPanel::load(workspace_handle.clone(), cx.clone()).await; 47 let Ok(willow_panel) = willow_panel else { 48 bail!("missing willow panel"); 49 }; 50 51 workspace_handle.update_in(cx, move |workspace, window, cx| { 52 workspace.add_panel(willow_panel, window, cx); 53 })?; 54 55 Ok(()) 56 }) 57 .detach(); 58 }, 59 ) 60 .detach(); 61 } 62 63 const WILLOW_PANEL_KEY: &str = "WillowPanel"; 64 65 #[derive(Serialize, Deserialize)] 66 struct SerializedWillowPanel { 67 width: Option<Pixels>, 68 } 69 70 // Remove old structures and use Entry from willow_ui instead 71 #[derive(Debug, Clone)] 72 enum DialogState { 73 None, 74 CreateDocument { path: String, _content: String }, 75 CreateSubspace {}, 76 ViewDocument { entry: Entry }, 77 } 78 79 pub struct WillowPanel { 80 width: Option<Pixels>, 81 pending_serialization: Task<Option<()>>, 82 focus_handle: FocusHandle, 83 entries: Vec<Entry>, 84 dialog_state: DialogState, 85 // TODO: Add Willow store when dependencies are working 86 // store: Arc<WillowStore>, 87 } 88 89 impl WillowPanel { 90 pub async fn load( 91 workspace: WeakEntity<Workspace>, 92 mut cx: AsyncWindowContext, 93 ) -> anyhow::Result<Entity<Self>> { 94 let serialized_panel = cx 95 .background_executor() 96 .spawn(async move { KEY_VALUE_STORE.read_kvp(WILLOW_PANEL_KEY) }) 97 .await 98 .context("loading willow panel") 99 .log_err() 100 .flatten() 101 .map(|panel| serde_json::from_str::<SerializedWillowPanel>(&panel)) 102 .transpose() 103 .log_err() 104 .flatten(); 105 106 workspace.update_in(&mut cx, |workspace, window, cx| { 107 let panel = Self::new(workspace, window, cx); 108 if let Some(serialized_panel) = serialized_panel { 109 panel.update(cx, |panel, cx| { 110 panel.width = serialized_panel.width.map(|px| px.round()); 111 cx.notify(); 112 }); 113 } 114 panel 115 }) 116 } 117 118 pub fn new( 119 workspace: &mut Workspace, 120 _window: &mut Window, 121 cx: &mut Context<Workspace>, 122 ) -> Entity<Self> { 123 let _user_store = workspace.app_state().user_store.clone(); 124 125 cx.new(|cx| { 126 let panel = Self { 127 width: None, 128 pending_serialization: Task::ready(None), 129 focus_handle: cx.focus_handle(), 130 entries: vec![ 131 // Sample data matching willow_ui entries 132 Entry { 133 namespace_id: "family".to_string(), 134 subspace_id: "alice".to_string(), 135 path: "/family/alice/Documents".to_string(), 136 timestamp: 1704067200, // 2 days ago 137 }, 138 Entry { 139 namespace_id: "family".to_string(), 140 subspace_id: "alice".to_string(), 141 path: "/family/alice/Photos".to_string(), 142 timestamp: 1703462400, // 1 week ago 143 }, 144 Entry { 145 namespace_id: "family".to_string(), 146 subspace_id: "bob".to_string(), 147 path: "/family/bob/Music".to_string(), 148 timestamp: 1703980800, // 3 days ago 149 }, 150 Entry { 151 namespace_id: "work".to_string(), 152 subspace_id: "projects".to_string(), 153 path: "/work/projects/willow-fs".to_string(), 154 timestamp: 1704153600, // 1 hour ago 155 }, 156 Entry { 157 namespace_id: "work".to_string(), 158 subspace_id: "projects".to_string(), 159 path: "/work/projects/presentation.pdf".to_string(), 160 timestamp: 1704067200, // Yesterday 161 }, 162 Entry { 163 namespace_id: "photos".to_string(), 164 subspace_id: "vacation_2024".to_string(), 165 path: "/photos/vacation_2024/beach.jpg".to_string(), 166 timestamp: 1702857600, // 2 weeks ago 167 }, 168 Entry { 169 namespace_id: "photos".to_string(), 170 subspace_id: "vacation_2024".to_string(), 171 path: "/photos/vacation_2024/mountains.jpg".to_string(), 172 timestamp: 1702857600, // 2 weeks ago 173 }, 174 ], 175 dialog_state: DialogState::None, 176 }; 177 178 panel 179 }) 180 } 181 182 pub fn create_document( 183 &mut self, 184 _action: &CreateDocument, 185 _window: &mut Window, 186 cx: &mut Context<Self>, 187 ) { 188 self.dialog_state = DialogState::CreateDocument { 189 path: String::new(), 190 _content: String::new(), 191 }; 192 cx.notify(); 193 } 194 195 pub fn create_subspace( 196 &mut self, 197 _action: &CreateSubspace, 198 _window: &mut Window, 199 cx: &mut Context<Self>, 200 ) { 201 self.dialog_state = DialogState::CreateSubspace {}; 202 cx.notify(); 203 } 204 205 pub fn close_dialog( 206 &mut self, 207 _action: &CloseDialog, 208 _window: &mut Window, 209 cx: &mut Context<Self>, 210 ) { 211 self.dialog_state = DialogState::None; 212 cx.notify(); 213 } 214 215 pub fn save_document( 216 &mut self, 217 _action: &SaveDocument, 218 _window: &mut Window, 219 cx: &mut Context<Self>, 220 ) { 221 // For demo purposes, create a mock entry 222 if let DialogState::CreateDocument { path, _content: _ } = &self.dialog_state { 223 if !path.is_empty() { 224 let new_entry = Entry { 225 namespace_id: "user".to_string(), 226 subspace_id: "documents".to_string(), 227 path: path.clone(), 228 timestamp: 1704157200, // Current timestamp (example) 229 }; 230 231 self.entries.push(new_entry); 232 self.dialog_state = DialogState::None; 233 cx.notify(); 234 } 235 } 236 } 237 238 pub fn delete_document(&mut self, entry: &Entry, _window: &mut Window, cx: &mut Context<Self>) { 239 self.entries.retain(|e| { 240 !(e.path == entry.path 241 && e.namespace_id == entry.namespace_id 242 && e.subspace_id == entry.subspace_id) 243 }); 244 cx.notify(); 245 } 246 } 247 248 impl Focusable for WillowPanel { 249 fn focus_handle(&self, _cx: &App) -> FocusHandle { 250 self.focus_handle.clone() 251 } 252 } 253 254 impl EventEmitter<PanelEvent> for WillowPanel {} 255 256 impl Panel for WillowPanel { 257 fn persistent_name() -> &'static str { 258 "Willow" 259 } 260 261 fn position(&self, _window: &Window, _cx: &App) -> DockPosition { 262 DockPosition::Left 263 } 264 265 fn position_is_valid(&self, position: DockPosition) -> bool { 266 matches!(position, DockPosition::Left | DockPosition::Right) 267 } 268 269 fn set_position( 270 &mut self, 271 _position: DockPosition, 272 _window: &mut Window, 273 _cx: &mut Context<Self>, 274 ) { 275 // Position changes are handled by the dock 276 } 277 278 fn size(&self, _window: &Window, _cx: &App) -> Pixels { 279 self.width.unwrap_or(px(320.)) 280 // .unwrap_or_else(|| { 281 // WillowPanelSettings::get_global(cx) 282 // .default_width 283 // .unwrap_or(px(320.)) 284 // }) 285 } 286 287 fn set_size(&mut self, size: Option<Pixels>, _window: &mut Window, cx: &mut Context<Self>) { 288 self.width = size; 289 self.serialize(cx); 290 } 291 292 fn icon(&self, _window: &Window, _cx: &App) -> Option<IconName> { 293 Some(IconName::DatabaseZap) 294 } 295 296 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { 297 Some("Willow Panel - Distributed data store explorer") 298 } 299 300 fn toggle_action(&self) -> Box<dyn Action> { 301 Box::new(ToggleFocus) 302 } 303 304 fn activation_priority(&self) -> u32 { 305 3 306 } 307 308 fn panel_key() -> &'static str { 309 "willow" 310 } 311 } 312 313 impl WillowPanel { 314 fn serialize(&mut self, cx: &mut Context<Self>) { 315 let width = self.width; 316 self.pending_serialization = cx.background_executor().spawn(async move { 317 KEY_VALUE_STORE 318 .write_kvp( 319 WILLOW_PANEL_KEY.into(), 320 serde_json::to_string(&SerializedWillowPanel { width }) 321 .unwrap() 322 .into(), 323 ) 324 .await 325 .log_err(); 326 Some(()) 327 }); 328 } 329 330 fn render_dialog(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> { 331 match &self.dialog_state { 332 DialogState::None => None, 333 DialogState::CreateDocument { .. } => Some( 334 div() 335 .absolute() 336 .top_0() 337 .left_0() 338 .size_full() 339 .bg(gpui::black().alpha(0.5)) 340 .flex() 341 .items_center() 342 .justify_center() 343 .child( 344 div() 345 .bg(cx.theme().colors().panel_background) 346 .border_1() 347 .border_color(cx.theme().colors().border) 348 .rounded_lg() 349 .p_4() 350 .w(px(400.)) 351 .child( 352 v_flex() 353 .gap_3() 354 .child( 355 Label::new("Create Document") 356 .size(LabelSize::Default) 357 .color(Color::Default), 358 ) 359 .child( 360 Label::new("Path: documents/new-file.txt") 361 .size(LabelSize::Small) 362 .color(Color::Muted), 363 ) 364 .child( 365 Label::new("Content: Hello, Willow!") 366 .size(LabelSize::Small) 367 .color(Color::Muted), 368 ) 369 .child( 370 h_flex() 371 .gap_2() 372 .justify_end() 373 .child( 374 IconButton::new("cancel-create", IconName::Close) 375 .on_click(cx.listener( 376 |this, _event, _window, cx| { 377 this.dialog_state = DialogState::None; 378 cx.notify(); 379 }, 380 )), 381 ) 382 .child( 383 IconButton::new("confirm-create", IconName::Check) 384 .on_click(cx.listener( 385 |this, _event, window, cx| { 386 this.save_document( 387 &SaveDocument, 388 window, 389 cx, 390 ); 391 }, 392 )), 393 ), 394 ), 395 ), 396 ), 397 ), 398 DialogState::CreateSubspace { .. } => Some( 399 div() 400 .absolute() 401 .top_0() 402 .left_0() 403 .size_full() 404 .bg(gpui::black().alpha(0.5)) 405 .flex() 406 .items_center() 407 .justify_center() 408 .child( 409 div() 410 .bg(cx.theme().colors().panel_background) 411 .border_1() 412 .border_color(cx.theme().colors().border) 413 .rounded_lg() 414 .p_4() 415 .w(px(400.)) 416 .child( 417 v_flex() 418 .gap_3() 419 .child( 420 Label::new("Create Subspace") 421 .size(LabelSize::Default) 422 .color(Color::Default), 423 ) 424 .child( 425 Label::new("Name: New Subspace") 426 .size(LabelSize::Small) 427 .color(Color::Muted), 428 ) 429 .child( 430 h_flex() 431 .gap_2() 432 .justify_end() 433 .child( 434 IconButton::new("cancel-subspace", IconName::Close) 435 .on_click(cx.listener( 436 |this, _event, _window, cx| { 437 this.dialog_state = DialogState::None; 438 cx.notify(); 439 }, 440 )), 441 ) 442 .child( 443 IconButton::new( 444 "confirm-subspace", 445 IconName::Check, 446 ) 447 .on_click(cx.listener( 448 |this, _event, _window, cx| { 449 // Create a sample entry for the new subspace 450 let new_entry = Entry { 451 namespace_id: "user".to_string(), 452 subspace_id: "new_subspace".to_string(), 453 path: "/user/new_subspace/welcome.txt" 454 .to_string(), 455 timestamp: 1704157200, // Current timestamp 456 }; 457 this.entries.push(new_entry); 458 this.dialog_state = DialogState::None; 459 cx.notify(); 460 }, 461 )), 462 ), 463 ), 464 ), 465 ), 466 ), 467 DialogState::ViewDocument { entry, .. } => Some( 468 div() 469 .absolute() 470 .top_0() 471 .left_0() 472 .size_full() 473 .bg(gpui::black().alpha(0.5)) 474 .flex() 475 .items_center() 476 .justify_center() 477 .child( 478 div() 479 .bg(cx.theme().colors().panel_background) 480 .border_1() 481 .border_color(cx.theme().colors().border) 482 .rounded_lg() 483 .p_4() 484 .w(px(600.)) 485 .h(px(500.)) 486 .child( 487 v_flex() 488 .gap_3() 489 .child( 490 h_flex() 491 .justify_between() 492 .items_center() 493 .child( 494 Label::new(&entry.path) 495 .size(LabelSize::Default) 496 .color(Color::Default), 497 ) 498 .child( 499 IconButton::new("close-document", IconName::Close) 500 .on_click(cx.listener( 501 |this, _event, _window, cx| { 502 this.dialog_state = DialogState::None; 503 cx.notify(); 504 }, 505 )), 506 ), 507 ) 508 .child( 509 div() 510 .flex_1() 511 .overflow_hidden() 512 .p_3() 513 .bg(cx.theme().colors().editor_background) 514 .rounded_md() 515 .border_1() 516 .border_color(cx.theme().colors().border_variant) 517 .child( 518 v_flex() 519 .gap_2() 520 .child( 521 h_flex() 522 .gap_2() 523 .child( 524 Label::new("Namespace:") 525 .size(LabelSize::Small) 526 .color(Color::Muted), 527 ) 528 .child( 529 Label::new(&entry.namespace_id) 530 .size(LabelSize::Small) 531 .color(Color::Default), 532 ), 533 ) 534 .child( 535 h_flex() 536 .gap_2() 537 .child( 538 Label::new("Subspace:") 539 .size(LabelSize::Small) 540 .color(Color::Muted), 541 ) 542 .child( 543 Label::new(&entry.subspace_id) 544 .size(LabelSize::Small) 545 .color(Color::Default), 546 ), 547 ) 548 .child( 549 h_flex() 550 .gap_2() 551 .child( 552 Label::new("Last Modified:") 553 .size(LabelSize::Small) 554 .color(Color::Muted), 555 ) 556 .child( 557 Label::new(&self.format_timestamp( 558 entry.timestamp, 559 )) 560 .size(LabelSize::Small) 561 .color(Color::Default), 562 ), 563 ), 564 ), 565 ), 566 ), 567 ), 568 ), 569 } 570 } 571 572 fn format_timestamp(&self, timestamp: i64) -> String { 573 // Simple timestamp formatting - in a real app you'd use a proper date library 574 match timestamp { 575 1704153600 => "1 hour ago".to_string(), 576 1704067200 => "Yesterday".to_string(), 577 1703980800 => "3 days ago".to_string(), 578 1703462400 => "1 week ago".to_string(), 579 1702857600 => "2 weeks ago".to_string(), 580 _ => format!("Timestamp: {}", timestamp), 581 } 582 } 583 584 fn get_file_icon(&self, path: &str) -> IconName { 585 let path_parts: Vec<&str> = path.split('/').collect(); 586 let name = path_parts.last().unwrap_or(&"").to_string(); 587 let is_directory = !name.contains('.'); 588 589 if is_directory { 590 IconName::Folder 591 } else { 592 match name.split('.').last().unwrap_or("") { 593 "jpg" | "jpeg" | "png" | "gif" | "bmp" => IconName::Image, 594 "pdf" => IconName::File, 595 "mp3" | "wav" | "flac" => IconName::File, 596 "mp4" | "avi" | "mkv" => IconName::File, 597 _ => IconName::File, 598 } 599 } 600 } 601 602 fn render_timeline(&self, cx: &mut Context<Self>) -> impl IntoElement { 603 let mut sorted_entries = self.entries.clone(); 604 sorted_entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); // Most recent first 605 606 v_flex() 607 .gap_2() 608 // Timeline header 609 .child( 610 h_flex() 611 .justify_between() 612 .items_center() 613 .child( 614 Label::new("Recent Activity") 615 .size(LabelSize::Small) 616 .color(Color::Muted), 617 ) 618 .child( 619 Label::new(format!("{}", sorted_entries.len())) 620 .size(LabelSize::XSmall) 621 .color(Color::Muted), 622 ), 623 ) 624 // Timeline entries 625 .children(sorted_entries.iter().enumerate().map(|(i, entry)| { 626 let path_parts: Vec<&str> = entry.path.split('/').collect(); 627 let filename = path_parts.last().unwrap_or(&"").to_string(); 628 let is_directory = !filename.contains('.'); 629 630 ListItem::new(("timeline_entry", i)) 631 .spacing(ListItemSpacing::Dense) 632 .start_slot( 633 Icon::new(self.get_file_icon(&entry.path)) 634 .size(IconSize::Small) 635 .color(if is_directory { 636 Color::Accent 637 } else { 638 Color::Default 639 }), 640 ) 641 .child( 642 v_flex() 643 .gap_1() 644 .child( 645 h_flex() 646 .gap_2() 647 .items_center() 648 .child( 649 Label::new(&filename) 650 .size(LabelSize::Small) 651 .color(Color::Default), 652 ) 653 .child( 654 h_flex() 655 .gap_1() 656 .child( 657 div() 658 .px_1p5() 659 .py_0p5() 660 .bg(cx.theme().colors().element_background) 661 .rounded_md() 662 .child( 663 Label::new(&entry.namespace_id) 664 .size(LabelSize::XSmall) 665 .color(Color::Accent), 666 ), 667 ) 668 .child( 669 div() 670 .px_1p5() 671 .py_0p5() 672 .bg(cx.theme().colors().element_background) 673 .rounded_md() 674 .child( 675 Label::new(&entry.subspace_id) 676 .size(LabelSize::XSmall) 677 .color(Color::Muted), 678 ), 679 ), 680 ), 681 ) 682 .child( 683 h_flex() 684 .gap_2() 685 .items_center() 686 .child( 687 Label::new(&entry.path) 688 .size(LabelSize::XSmall) 689 .color(Color::Muted), 690 ) 691 .child( 692 Label::new("•").size(LabelSize::XSmall).color(Color::Muted), 693 ) 694 .child( 695 Label::new(&self.format_timestamp(entry.timestamp)) 696 .size(LabelSize::XSmall) 697 .color(Color::Muted), 698 ), 699 ), 700 ) 701 .end_slot( 702 h_flex() 703 .gap_1() 704 .child(IconButton::new(("view", i), IconName::Eye).on_click({ 705 let entry_clone = entry.clone(); 706 cx.listener(move |this, _event, _window, cx| { 707 this.dialog_state = DialogState::ViewDocument { 708 entry: entry_clone.clone(), 709 }; 710 cx.notify(); 711 }) 712 })) 713 .child(IconButton::new(("delete", i), IconName::Trash).on_click({ 714 let entry_clone = entry.clone(); 715 cx.listener(move |this, _event, window, cx| { 716 this.delete_document(&entry_clone, window, cx); 717 }) 718 })), 719 ) 720 })) 721 .when(sorted_entries.is_empty(), |this| { 722 this.child( 723 Label::new("No entries yet. Create some content to get started!") 724 .size(LabelSize::XSmall) 725 .color(Color::Muted), 726 ) 727 }) 728 } 729 } 730 731 impl Render for WillowPanel { 732 fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { 733 let main_content = v_flex() 734 .size_full() 735 .bg(cx.theme().colors().panel_background) 736 .on_action(cx.listener(Self::create_document)) 737 .on_action(cx.listener(Self::create_subspace)) 738 .on_action(cx.listener(Self::close_dialog)) 739 .on_action(cx.listener(Self::save_document)) 740 // Sidebar header 741 .child( 742 h_flex() 743 .w_full() 744 .justify_between() 745 .p_2() 746 .border_b_1() 747 .border_color(cx.theme().colors().border_variant) 748 .child( 749 Label::new("Willow Data Store") 750 .size(LabelSize::Default) 751 .color(Color::Default), 752 ) 753 .child( 754 h_flex() 755 .gap_2() 756 .child(IconButton::new("create-document", IconName::File).on_click( 757 cx.listener(|this, _event, window, cx| { 758 this.create_document(&CreateDocument, window, cx); 759 }), 760 )) 761 .child( 762 IconButton::new("create-subspace", IconName::Person).on_click( 763 cx.listener(|this, _event, window, cx| { 764 this.create_subspace(&CreateSubspace, window, cx); 765 }), 766 ), 767 ), 768 ), 769 ) 770 // Sidebar body 771 .child( 772 div().flex_1().overflow_hidden().p_3().child( 773 v_flex() 774 .gap_3() 775 // Subspaces section 776 .child(self.render_timeline(cx)), 777 ), 778 ); 779 780 if let Some(dialog) = self.render_dialog(cx) { 781 div().relative().child(main_content).child(dialog) 782 } else { 783 main_content 784 } 785 } 786 }