project_panel.rs

   1pub mod file_associations;
   2mod project_panel_settings;
   3use settings::{Settings, SettingsStore};
   4
   5use db::kvp::KEY_VALUE_STORE;
   6use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
   7use file_associations::FileAssociations;
   8
   9use anyhow::{anyhow, Result};
  10use gpui::{
  11    actions, div, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
  12    ClipboardItem, Div, EventEmitter, FocusHandle, Focusable, FocusableView, InteractiveElement,
  13    Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render,
  14    RenderOnce, Stateful, StatefulInteractiveElement, Styled, Task, UniformListScrollHandle, View,
  15    ViewContext, VisualContext as _, WeakView, WindowContext,
  16};
  17use menu::{Confirm, SelectNext, SelectPrev};
  18use project::{
  19    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
  20    Worktree, WorktreeId,
  21};
  22use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
  23use serde::{Deserialize, Serialize};
  24use std::{
  25    cmp::Ordering,
  26    collections::{hash_map, HashMap},
  27    ffi::OsStr,
  28    ops::Range,
  29    path::Path,
  30    sync::Arc,
  31};
  32use theme::ActiveTheme as _;
  33use ui::{h_stack, v_stack, IconElement, Label};
  34use unicase::UniCase;
  35use util::{maybe, ResultExt, TryFutureExt};
  36use workspace::{
  37    dock::{DockPosition, Panel, PanelEvent},
  38    Workspace,
  39};
  40
  41const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
  42const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  43
  44pub struct ProjectPanel {
  45    project: Model<Project>,
  46    fs: Arc<dyn Fs>,
  47    list: UniformListScrollHandle,
  48    focus_handle: FocusHandle,
  49    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  50    last_worktree_root_id: Option<ProjectEntryId>,
  51    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  52    selection: Option<Selection>,
  53    edit_state: Option<EditState>,
  54    filename_editor: View<Editor>,
  55    clipboard_entry: Option<ClipboardEntry>,
  56    _dragged_entry_destination: Option<Arc<Path>>,
  57    _workspace: WeakView<Workspace>,
  58    has_focus: bool,
  59    width: Option<f32>,
  60    pending_serialization: Task<Option<()>>,
  61}
  62
  63#[derive(Copy, Clone, Debug)]
  64struct Selection {
  65    worktree_id: WorktreeId,
  66    entry_id: ProjectEntryId,
  67}
  68
  69#[derive(Clone, Debug)]
  70struct EditState {
  71    worktree_id: WorktreeId,
  72    entry_id: ProjectEntryId,
  73    is_new_entry: bool,
  74    is_dir: bool,
  75    processing_filename: Option<String>,
  76}
  77
  78#[derive(Copy, Clone)]
  79pub enum ClipboardEntry {
  80    Copied {
  81        worktree_id: WorktreeId,
  82        entry_id: ProjectEntryId,
  83    },
  84    Cut {
  85        worktree_id: WorktreeId,
  86        entry_id: ProjectEntryId,
  87    },
  88}
  89
  90#[derive(Debug, PartialEq, Eq)]
  91pub struct EntryDetails {
  92    filename: String,
  93    icon: Option<Arc<str>>,
  94    path: Arc<Path>,
  95    depth: usize,
  96    kind: EntryKind,
  97    is_ignored: bool,
  98    is_expanded: bool,
  99    is_selected: bool,
 100    is_editing: bool,
 101    is_processing: bool,
 102    is_cut: bool,
 103    git_status: Option<GitFileStatus>,
 104}
 105
 106actions!(
 107    ExpandSelectedEntry,
 108    CollapseSelectedEntry,
 109    CollapseAllEntries,
 110    NewDirectory,
 111    NewFile,
 112    Copy,
 113    CopyPath,
 114    CopyRelativePath,
 115    RevealInFinder,
 116    OpenInTerminal,
 117    Cut,
 118    Paste,
 119    Delete,
 120    Rename,
 121    Open,
 122    ToggleFocus,
 123    NewSearchInDirectory,
 124);
 125
 126pub fn init_settings(cx: &mut AppContext) {
 127    ProjectPanelSettings::register(cx);
 128}
 129
 130pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 131    init_settings(cx);
 132    file_associations::init(assets, cx);
 133
 134    cx.observe_new_views(|workspace: &mut Workspace, _| {
 135        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 136            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 137        });
 138    })
 139    .detach();
 140}
 141
 142#[derive(Debug)]
 143pub enum Event {
 144    OpenedEntry {
 145        entry_id: ProjectEntryId,
 146        focus_opened_item: bool,
 147    },
 148    SplitEntry {
 149        entry_id: ProjectEntryId,
 150    },
 151    Focus,
 152    NewSearchInDirectory {
 153        dir_entry: Entry,
 154    },
 155    ActivatePanel,
 156}
 157
 158#[derive(Serialize, Deserialize)]
 159struct SerializedProjectPanel {
 160    width: Option<f32>,
 161}
 162
 163impl ProjectPanel {
 164    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 165        let project = workspace.project().clone();
 166        let project_panel = cx.build_view(|cx: &mut ViewContext<Self>| {
 167            cx.observe(&project, |this, _, cx| {
 168                this.update_visible_entries(None, cx);
 169                cx.notify();
 170            })
 171            .detach();
 172            let focus_handle = cx.focus_handle();
 173
 174            cx.on_focus(&focus_handle, Self::focus_in).detach();
 175            cx.on_blur(&focus_handle, Self::focus_out).detach();
 176
 177            cx.subscribe(&project, |this, project, event, cx| match event {
 178                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 179                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 180                    {
 181                        this.expand_entry(worktree_id, *entry_id, cx);
 182                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 183                        this.autoscroll(cx);
 184                        cx.notify();
 185                    }
 186                }
 187                project::Event::ActivateProjectPanel => {
 188                    cx.emit(Event::ActivatePanel);
 189                }
 190                project::Event::WorktreeRemoved(id) => {
 191                    this.expanded_dir_ids.remove(id);
 192                    this.update_visible_entries(None, cx);
 193                    cx.notify();
 194                }
 195                _ => {}
 196            })
 197            .detach();
 198
 199            let filename_editor = cx.build_view(|cx| Editor::single_line(cx));
 200
 201            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 202                editor::EditorEvent::BufferEdited
 203                | editor::EditorEvent::SelectionsChanged { .. } => {
 204                    this.autoscroll(cx);
 205                }
 206                editor::EditorEvent::Blurred => {
 207                    if this
 208                        .edit_state
 209                        .as_ref()
 210                        .map_or(false, |state| state.processing_filename.is_none())
 211                    {
 212                        this.edit_state = None;
 213                        this.update_visible_entries(None, cx);
 214                    }
 215                }
 216                _ => {}
 217            })
 218            .detach();
 219
 220            // cx.observe_global::<FileAssociations, _>(|_, cx| {
 221            //     cx.notify();
 222            // })
 223            // .detach();
 224
 225            let mut this = Self {
 226                project: project.clone(),
 227                fs: workspace.app_state().fs.clone(),
 228                list: UniformListScrollHandle::new(),
 229                focus_handle,
 230                visible_entries: Default::default(),
 231                last_worktree_root_id: Default::default(),
 232                expanded_dir_ids: Default::default(),
 233                selection: None,
 234                edit_state: None,
 235                filename_editor,
 236                clipboard_entry: None,
 237                // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
 238                _dragged_entry_destination: None,
 239                _workspace: workspace.weak_handle(),
 240                has_focus: false,
 241                width: None,
 242                pending_serialization: Task::ready(None),
 243            };
 244            this.update_visible_entries(None, cx);
 245
 246            // Update the dock position when the setting changes.
 247            let mut old_dock_position = this.position(cx);
 248            ProjectPanelSettings::register(cx);
 249            cx.observe_global::<SettingsStore>(move |this, cx| {
 250                let new_dock_position = this.position(cx);
 251                if new_dock_position != old_dock_position {
 252                    old_dock_position = new_dock_position;
 253                    cx.emit(PanelEvent::ChangePosition);
 254                }
 255            })
 256            .detach();
 257
 258            this
 259        });
 260
 261        cx.subscribe(&project_panel, {
 262            let project_panel = project_panel.downgrade();
 263            move |workspace, _, event, cx| match event {
 264                &Event::OpenedEntry {
 265                    entry_id,
 266                    focus_opened_item,
 267                } => {
 268                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 269                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 270                            workspace
 271                                .open_path(
 272                                    ProjectPath {
 273                                        worktree_id: worktree.read(cx).id(),
 274                                        path: entry.path.clone(),
 275                                    },
 276                                    None,
 277                                    focus_opened_item,
 278                                    cx,
 279                                )
 280                                .detach_and_log_err(cx);
 281                            if !focus_opened_item {
 282                                if let Some(project_panel) = project_panel.upgrade() {
 283                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 284                                    cx.focus(&focus_handle);
 285                                }
 286                            }
 287                        }
 288                    }
 289                }
 290                &Event::SplitEntry { entry_id } => {
 291                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 292                        if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
 293                            // workspace
 294                            //     .split_path(
 295                            //         ProjectPath {
 296                            //             worktree_id: worktree.read(cx).id(),
 297                            //             path: entry.path.clone(),
 298                            //         },
 299                            //         cx,
 300                            //     )
 301                            //     .detach_and_log_err(cx);
 302                        }
 303                    }
 304                }
 305                _ => {}
 306            }
 307        })
 308        .detach();
 309
 310        project_panel
 311    }
 312
 313    pub async fn load(
 314        workspace: WeakView<Workspace>,
 315        mut cx: AsyncWindowContext,
 316    ) -> Result<View<Self>> {
 317        let serialized_panel = cx
 318            .background_executor()
 319            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 320            .await
 321            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 322            .log_err()
 323            .flatten()
 324            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 325            .transpose()
 326            .log_err()
 327            .flatten();
 328
 329        workspace.update(&mut cx, |workspace, cx| {
 330            let panel = ProjectPanel::new(workspace, cx);
 331            if let Some(serialized_panel) = serialized_panel {
 332                panel.update(cx, |panel, cx| {
 333                    panel.width = serialized_panel.width;
 334                    cx.notify();
 335                });
 336            }
 337            panel
 338        })
 339    }
 340
 341    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 342        let width = self.width;
 343        self.pending_serialization = cx.background_executor().spawn(
 344            async move {
 345                KEY_VALUE_STORE
 346                    .write_kvp(
 347                        PROJECT_PANEL_KEY.into(),
 348                        serde_json::to_string(&SerializedProjectPanel { width })?,
 349                    )
 350                    .await?;
 351                anyhow::Ok(())
 352            }
 353            .log_err(),
 354        );
 355    }
 356
 357    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 358        if !self.has_focus {
 359            self.has_focus = true;
 360            cx.emit(Event::Focus);
 361        }
 362    }
 363
 364    fn focus_out(&mut self, _: &mut ViewContext<Self>) {
 365        self.has_focus = false;
 366    }
 367
 368    fn deploy_context_menu(
 369        &mut self,
 370        _position: Point<Pixels>,
 371        _entry_id: ProjectEntryId,
 372        _cx: &mut ViewContext<Self>,
 373    ) {
 374        // todo!()
 375        //     let project = self.project.read(cx);
 376
 377        //     let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 378        //         id
 379        //     } else {
 380        //         return;
 381        //     };
 382
 383        //     self.selection = Some(Selection {
 384        //         worktree_id,
 385        //         entry_id,
 386        //     });
 387
 388        //     let mut menu_entries = Vec::new();
 389        //     if let Some((worktree, entry)) = self.selected_entry(cx) {
 390        //         let is_root = Some(entry) == worktree.root_entry();
 391        //         if !project.is_remote() {
 392        //             menu_entries.push(ContextMenuItem::action(
 393        //                 "Add Folder to Project",
 394        //                 workspace::AddFolderToProject,
 395        //             ));
 396        //             if is_root {
 397        //                 let project = self.project.clone();
 398        //                 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
 399        //                     project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
 400        //                 }));
 401        //             }
 402        //         }
 403        //         menu_entries.push(ContextMenuItem::action("New File", NewFile));
 404        //         menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
 405        //         menu_entries.push(ContextMenuItem::Separator);
 406        //         menu_entries.push(ContextMenuItem::action("Cut", Cut));
 407        //         menu_entries.push(ContextMenuItem::action("Copy", Copy));
 408        //         if let Some(clipboard_entry) = self.clipboard_entry {
 409        //             if clipboard_entry.worktree_id() == worktree.id() {
 410        //                 menu_entries.push(ContextMenuItem::action("Paste", Paste));
 411        //             }
 412        //         }
 413        //         menu_entries.push(ContextMenuItem::Separator);
 414        //         menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
 415        //         menu_entries.push(ContextMenuItem::action(
 416        //             "Copy Relative Path",
 417        //             CopyRelativePath,
 418        //         ));
 419
 420        //         if entry.is_dir() {
 421        //             menu_entries.push(ContextMenuItem::Separator);
 422        //         }
 423        //         menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
 424        //         if entry.is_dir() {
 425        //             menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
 426        //             menu_entries.push(ContextMenuItem::action(
 427        //                 "Search Inside",
 428        //                 NewSearchInDirectory,
 429        //             ));
 430        //         }
 431
 432        //         menu_entries.push(ContextMenuItem::Separator);
 433        //         menu_entries.push(ContextMenuItem::action("Rename", Rename));
 434        //         if !is_root {
 435        //             menu_entries.push(ContextMenuItem::action("Delete", Delete));
 436        //         }
 437        //     }
 438
 439        //     // self.context_menu.update(cx, |menu, cx| {
 440        //     //     menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
 441        //     // });
 442
 443        //     cx.notify();
 444    }
 445
 446    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 447        if let Some((worktree, entry)) = self.selected_entry(cx) {
 448            if entry.is_dir() {
 449                let worktree_id = worktree.id();
 450                let entry_id = entry.id;
 451                let expanded_dir_ids =
 452                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 453                        expanded_dir_ids
 454                    } else {
 455                        return;
 456                    };
 457
 458                match expanded_dir_ids.binary_search(&entry_id) {
 459                    Ok(_) => self.select_next(&SelectNext, cx),
 460                    Err(ix) => {
 461                        self.project.update(cx, |project, cx| {
 462                            project.expand_entry(worktree_id, entry_id, cx);
 463                        });
 464
 465                        expanded_dir_ids.insert(ix, entry_id);
 466                        self.update_visible_entries(None, cx);
 467                        cx.notify();
 468                    }
 469                }
 470            }
 471        }
 472    }
 473
 474    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 475        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 476            let worktree_id = worktree.id();
 477            let expanded_dir_ids =
 478                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 479                    expanded_dir_ids
 480                } else {
 481                    return;
 482                };
 483
 484            loop {
 485                let entry_id = entry.id;
 486                match expanded_dir_ids.binary_search(&entry_id) {
 487                    Ok(ix) => {
 488                        expanded_dir_ids.remove(ix);
 489                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 490                        cx.notify();
 491                        break;
 492                    }
 493                    Err(_) => {
 494                        if let Some(parent_entry) =
 495                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 496                        {
 497                            entry = parent_entry;
 498                        } else {
 499                            break;
 500                        }
 501                    }
 502                }
 503            }
 504        }
 505    }
 506
 507    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 508        self.expanded_dir_ids.clear();
 509        self.update_visible_entries(None, cx);
 510        cx.notify();
 511    }
 512
 513    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 514        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 515            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 516                self.project.update(cx, |project, cx| {
 517                    match expanded_dir_ids.binary_search(&entry_id) {
 518                        Ok(ix) => {
 519                            expanded_dir_ids.remove(ix);
 520                        }
 521                        Err(ix) => {
 522                            project.expand_entry(worktree_id, entry_id, cx);
 523                            expanded_dir_ids.insert(ix, entry_id);
 524                        }
 525                    }
 526                });
 527                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 528                cx.focus(&self.focus_handle);
 529                cx.notify();
 530            }
 531        }
 532    }
 533
 534    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 535        if let Some(selection) = self.selection {
 536            let (mut worktree_ix, mut entry_ix, _) =
 537                self.index_for_selection(selection).unwrap_or_default();
 538            if entry_ix > 0 {
 539                entry_ix -= 1;
 540            } else if worktree_ix > 0 {
 541                worktree_ix -= 1;
 542                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 543            } else {
 544                return;
 545            }
 546
 547            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 548            self.selection = Some(Selection {
 549                worktree_id: *worktree_id,
 550                entry_id: worktree_entries[entry_ix].id,
 551            });
 552            self.autoscroll(cx);
 553            cx.notify();
 554        } else {
 555            self.select_first(cx);
 556        }
 557    }
 558
 559    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 560        if let Some(task) = self.confirm_edit(cx) {
 561            task.detach_and_log_err(cx);
 562        }
 563    }
 564
 565    fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 566        if let Some((_, entry)) = self.selected_entry(cx) {
 567            if entry.is_file() {
 568                self.open_entry(entry.id, true, cx);
 569            }
 570        }
 571    }
 572
 573    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 574        let edit_state = self.edit_state.as_mut()?;
 575        cx.focus(&self.focus_handle);
 576
 577        let worktree_id = edit_state.worktree_id;
 578        let is_new_entry = edit_state.is_new_entry;
 579        let is_dir = edit_state.is_dir;
 580        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 581        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 582        let filename = self.filename_editor.read(cx).text(cx);
 583
 584        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 585        let edit_task;
 586        let edited_entry_id;
 587        if is_new_entry {
 588            self.selection = Some(Selection {
 589                worktree_id,
 590                entry_id: NEW_ENTRY_ID,
 591            });
 592            let new_path = entry.path.join(&filename.trim_start_matches("/"));
 593            if path_already_exists(new_path.as_path()) {
 594                return None;
 595            }
 596
 597            edited_entry_id = NEW_ENTRY_ID;
 598            edit_task = self.project.update(cx, |project, cx| {
 599                project.create_entry((worktree_id, &new_path), is_dir, cx)
 600            })?;
 601        } else {
 602            let new_path = if let Some(parent) = entry.path.clone().parent() {
 603                parent.join(&filename)
 604            } else {
 605                filename.clone().into()
 606            };
 607            if path_already_exists(new_path.as_path()) {
 608                return None;
 609            }
 610
 611            edited_entry_id = entry.id;
 612            edit_task = self.project.update(cx, |project, cx| {
 613                project.rename_entry(entry.id, new_path.as_path(), cx)
 614            })?;
 615        };
 616
 617        edit_state.processing_filename = Some(filename);
 618        cx.notify();
 619
 620        Some(cx.spawn(|this, mut cx| async move {
 621            let new_entry = edit_task.await;
 622            this.update(&mut cx, |this, cx| {
 623                this.edit_state.take();
 624                cx.notify();
 625            })?;
 626
 627            let new_entry = new_entry?;
 628            this.update(&mut cx, |this, cx| {
 629                if let Some(selection) = &mut this.selection {
 630                    if selection.entry_id == edited_entry_id {
 631                        selection.worktree_id = worktree_id;
 632                        selection.entry_id = new_entry.id;
 633                        this.expand_to_selection(cx);
 634                    }
 635                }
 636                this.update_visible_entries(None, cx);
 637                if is_new_entry && !is_dir {
 638                    this.open_entry(new_entry.id, true, cx);
 639                }
 640                cx.notify();
 641            })?;
 642            Ok(())
 643        }))
 644    }
 645
 646    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 647        self.edit_state = None;
 648        self.update_visible_entries(None, cx);
 649        cx.focus(&self.focus_handle);
 650        cx.notify();
 651    }
 652
 653    fn open_entry(
 654        &mut self,
 655        entry_id: ProjectEntryId,
 656        focus_opened_item: bool,
 657        cx: &mut ViewContext<Self>,
 658    ) {
 659        cx.emit(Event::OpenedEntry {
 660            entry_id,
 661            focus_opened_item,
 662        });
 663    }
 664
 665    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 666        cx.emit(Event::SplitEntry { entry_id });
 667    }
 668
 669    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 670        self.add_entry(false, cx)
 671    }
 672
 673    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 674        self.add_entry(true, cx)
 675    }
 676
 677    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 678        if let Some(Selection {
 679            worktree_id,
 680            entry_id,
 681        }) = self.selection
 682        {
 683            let directory_id;
 684            if let Some((worktree, expanded_dir_ids)) = self
 685                .project
 686                .read(cx)
 687                .worktree_for_id(worktree_id, cx)
 688                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 689            {
 690                let worktree = worktree.read(cx);
 691                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 692                    loop {
 693                        if entry.is_dir() {
 694                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 695                                expanded_dir_ids.insert(ix, entry.id);
 696                            }
 697                            directory_id = entry.id;
 698                            break;
 699                        } else {
 700                            if let Some(parent_path) = entry.path.parent() {
 701                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 702                                    entry = parent_entry;
 703                                    continue;
 704                                }
 705                            }
 706                            return;
 707                        }
 708                    }
 709                } else {
 710                    return;
 711                };
 712            } else {
 713                return;
 714            };
 715
 716            self.edit_state = Some(EditState {
 717                worktree_id,
 718                entry_id: directory_id,
 719                is_new_entry: true,
 720                is_dir,
 721                processing_filename: None,
 722            });
 723            self.filename_editor.update(cx, |editor, cx| {
 724                editor.clear(cx);
 725                editor.focus(cx);
 726            });
 727            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 728            self.autoscroll(cx);
 729            cx.notify();
 730        }
 731    }
 732
 733    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 734        if let Some(Selection {
 735            worktree_id,
 736            entry_id,
 737        }) = self.selection
 738        {
 739            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 740                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 741                    self.edit_state = Some(EditState {
 742                        worktree_id,
 743                        entry_id,
 744                        is_new_entry: false,
 745                        is_dir: entry.is_dir(),
 746                        processing_filename: None,
 747                    });
 748                    let file_name = entry
 749                        .path
 750                        .file_name()
 751                        .map(|s| s.to_string_lossy())
 752                        .unwrap_or_default()
 753                        .to_string();
 754                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 755                    let selection_end =
 756                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 757                    self.filename_editor.update(cx, |editor, cx| {
 758                        editor.set_text(file_name, cx);
 759                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 760                            s.select_ranges([0..selection_end])
 761                        });
 762                        editor.focus(cx);
 763                    });
 764                    self.update_visible_entries(None, cx);
 765                    self.autoscroll(cx);
 766                    cx.notify();
 767                }
 768            }
 769
 770            // cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 771            //     drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 772            // })
 773        }
 774    }
 775
 776    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
 777        maybe!({
 778            let Selection { entry_id, .. } = self.selection?;
 779            let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 780            let file_name = path.file_name()?;
 781
 782            let answer = cx.prompt(
 783                PromptLevel::Info,
 784                &format!("Delete {file_name:?}?"),
 785                &["Delete", "Cancel"],
 786            );
 787
 788            cx.spawn(|this, mut cx| async move {
 789                if answer.await != Ok(0) {
 790                    return Ok(());
 791                }
 792                this.update(&mut cx, |this, cx| {
 793                    this.project
 794                        .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 795                        .ok_or_else(|| anyhow!("no such entry"))
 796                })??
 797                .await
 798            })
 799            .detach_and_log_err(cx);
 800            Some(())
 801        });
 802    }
 803
 804    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 805        if let Some(selection) = self.selection {
 806            let (mut worktree_ix, mut entry_ix, _) =
 807                self.index_for_selection(selection).unwrap_or_default();
 808            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 809                if entry_ix + 1 < worktree_entries.len() {
 810                    entry_ix += 1;
 811                } else {
 812                    worktree_ix += 1;
 813                    entry_ix = 0;
 814                }
 815            }
 816
 817            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 818                if let Some(entry) = worktree_entries.get(entry_ix) {
 819                    self.selection = Some(Selection {
 820                        worktree_id: *worktree_id,
 821                        entry_id: entry.id,
 822                    });
 823                    self.autoscroll(cx);
 824                    cx.notify();
 825                }
 826            }
 827        } else {
 828            self.select_first(cx);
 829        }
 830    }
 831
 832    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 833        let worktree = self
 834            .visible_entries
 835            .first()
 836            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 837        if let Some(worktree) = worktree {
 838            let worktree = worktree.read(cx);
 839            let worktree_id = worktree.id();
 840            if let Some(root_entry) = worktree.root_entry() {
 841                self.selection = Some(Selection {
 842                    worktree_id,
 843                    entry_id: root_entry.id,
 844                });
 845                self.autoscroll(cx);
 846                cx.notify();
 847            }
 848        }
 849    }
 850
 851    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 852        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 853            self.list.scroll_to_item(index);
 854            cx.notify();
 855        }
 856    }
 857
 858    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 859        if let Some((worktree, entry)) = self.selected_entry(cx) {
 860            self.clipboard_entry = Some(ClipboardEntry::Cut {
 861                worktree_id: worktree.id(),
 862                entry_id: entry.id,
 863            });
 864            cx.notify();
 865        }
 866    }
 867
 868    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 869        if let Some((worktree, entry)) = self.selected_entry(cx) {
 870            self.clipboard_entry = Some(ClipboardEntry::Copied {
 871                worktree_id: worktree.id(),
 872                entry_id: entry.id,
 873            });
 874            cx.notify();
 875        }
 876    }
 877
 878    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
 879        maybe!({
 880            let (worktree, entry) = self.selected_entry(cx)?;
 881            let clipboard_entry = self.clipboard_entry?;
 882            if clipboard_entry.worktree_id() != worktree.id() {
 883                return None;
 884            }
 885
 886            let clipboard_entry_file_name = self
 887                .project
 888                .read(cx)
 889                .path_for_entry(clipboard_entry.entry_id(), cx)?
 890                .path
 891                .file_name()?
 892                .to_os_string();
 893
 894            let mut new_path = entry.path.to_path_buf();
 895            if entry.is_file() {
 896                new_path.pop();
 897            }
 898
 899            new_path.push(&clipboard_entry_file_name);
 900            let extension = new_path.extension().map(|e| e.to_os_string());
 901            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 902            let mut ix = 0;
 903            while worktree.entry_for_path(&new_path).is_some() {
 904                new_path.pop();
 905
 906                let mut new_file_name = file_name_without_extension.to_os_string();
 907                new_file_name.push(" copy");
 908                if ix > 0 {
 909                    new_file_name.push(format!(" {}", ix));
 910                }
 911                if let Some(extension) = extension.as_ref() {
 912                    new_file_name.push(".");
 913                    new_file_name.push(extension);
 914                }
 915
 916                new_path.push(new_file_name);
 917                ix += 1;
 918            }
 919
 920            if clipboard_entry.is_cut() {
 921                if let Some(task) = self.project.update(cx, |project, cx| {
 922                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 923                }) {
 924                    task.detach_and_log_err(cx);
 925                }
 926            } else if let Some(task) = self.project.update(cx, |project, cx| {
 927                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 928            }) {
 929                task.detach_and_log_err(cx);
 930            }
 931
 932            Some(())
 933        });
 934    }
 935
 936    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 937        if let Some((worktree, entry)) = self.selected_entry(cx) {
 938            cx.write_to_clipboard(ClipboardItem::new(
 939                worktree
 940                    .abs_path()
 941                    .join(&entry.path)
 942                    .to_string_lossy()
 943                    .to_string(),
 944            ));
 945        }
 946    }
 947
 948    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 949        if let Some((_, entry)) = self.selected_entry(cx) {
 950            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 951        }
 952    }
 953
 954    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 955        if let Some((worktree, entry)) = self.selected_entry(cx) {
 956            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 957        }
 958    }
 959
 960    fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
 961        todo!()
 962        // if let Some((worktree, entry)) = self.selected_entry(cx) {
 963        //     let window = cx.window();
 964        //     let view_id = cx.view_id();
 965        //     let path = worktree.abs_path().join(&entry.path);
 966
 967        //     cx.app_context()
 968        //         .spawn(|mut cx| async move {
 969        //             window.dispatch_action(
 970        //                 view_id,
 971        //                 &workspace::OpenTerminal {
 972        //                     working_directory: path,
 973        //                 },
 974        //                 &mut cx,
 975        //             );
 976        //         })
 977        //         .detach();
 978        // }
 979    }
 980
 981    pub fn new_search_in_directory(
 982        &mut self,
 983        _: &NewSearchInDirectory,
 984        cx: &mut ViewContext<Self>,
 985    ) {
 986        if let Some((_, entry)) = self.selected_entry(cx) {
 987            if entry.is_dir() {
 988                cx.emit(Event::NewSearchInDirectory {
 989                    dir_entry: entry.clone(),
 990                });
 991            }
 992        }
 993    }
 994
 995    // todo!()
 996    // fn move_entry(
 997    //     &mut self,
 998    //     entry_to_move: ProjectEntryId,
 999    //     destination: ProjectEntryId,
1000    //     destination_is_file: bool,
1001    //     cx: &mut ViewContext<Self>,
1002    // ) {
1003    //     let destination_worktree = self.project.update(cx, |project, cx| {
1004    //         let entry_path = project.path_for_entry(entry_to_move, cx)?;
1005    //         let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1006
1007    //         let mut destination_path = destination_entry_path.as_ref();
1008    //         if destination_is_file {
1009    //             destination_path = destination_path.parent()?;
1010    //         }
1011
1012    //         let mut new_path = destination_path.to_path_buf();
1013    //         new_path.push(entry_path.path.file_name()?);
1014    //         if new_path != entry_path.path.as_ref() {
1015    //             let task = project.rename_entry(entry_to_move, new_path, cx)?;
1016    //             cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1017    //         }
1018
1019    //         Some(project.worktree_id_for_entry(destination, cx)?)
1020    //     });
1021
1022    //     if let Some(destination_worktree) = destination_worktree {
1023    //         self.expand_entry(destination_worktree, destination, cx);
1024    //     }
1025    // }
1026
1027    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1028        let mut entry_index = 0;
1029        let mut visible_entries_index = 0;
1030        for (worktree_index, (worktree_id, worktree_entries)) in
1031            self.visible_entries.iter().enumerate()
1032        {
1033            if *worktree_id == selection.worktree_id {
1034                for entry in worktree_entries {
1035                    if entry.id == selection.entry_id {
1036                        return Some((worktree_index, entry_index, visible_entries_index));
1037                    } else {
1038                        visible_entries_index += 1;
1039                        entry_index += 1;
1040                    }
1041                }
1042                break;
1043            } else {
1044                visible_entries_index += worktree_entries.len();
1045            }
1046        }
1047        None
1048    }
1049
1050    pub fn selected_entry<'a>(
1051        &self,
1052        cx: &'a AppContext,
1053    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1054        let (worktree, entry) = self.selected_entry_handle(cx)?;
1055        Some((worktree.read(cx), entry))
1056    }
1057
1058    fn selected_entry_handle<'a>(
1059        &self,
1060        cx: &'a AppContext,
1061    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1062        let selection = self.selection?;
1063        let project = self.project.read(cx);
1064        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1065        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1066        Some((worktree, entry))
1067    }
1068
1069    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1070        let (worktree, entry) = self.selected_entry(cx)?;
1071        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1072
1073        for path in entry.path.ancestors() {
1074            let Some(entry) = worktree.entry_for_path(path) else {
1075                continue;
1076            };
1077            if entry.is_dir() {
1078                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1079                    expanded_dir_ids.insert(idx, entry.id);
1080                }
1081            }
1082        }
1083
1084        Some(())
1085    }
1086
1087    fn update_visible_entries(
1088        &mut self,
1089        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1090        cx: &mut ViewContext<Self>,
1091    ) {
1092        let project = self.project.read(cx);
1093        self.last_worktree_root_id = project
1094            .visible_worktrees(cx)
1095            .rev()
1096            .next()
1097            .and_then(|worktree| worktree.read(cx).root_entry())
1098            .map(|entry| entry.id);
1099
1100        self.visible_entries.clear();
1101        for worktree in project.visible_worktrees(cx) {
1102            let snapshot = worktree.read(cx).snapshot();
1103            let worktree_id = snapshot.id();
1104
1105            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1106                hash_map::Entry::Occupied(e) => e.into_mut(),
1107                hash_map::Entry::Vacant(e) => {
1108                    // The first time a worktree's root entry becomes available,
1109                    // mark that root entry as expanded.
1110                    if let Some(entry) = snapshot.root_entry() {
1111                        e.insert(vec![entry.id]).as_slice()
1112                    } else {
1113                        &[]
1114                    }
1115                }
1116            };
1117
1118            let mut new_entry_parent_id = None;
1119            let mut new_entry_kind = EntryKind::Dir;
1120            if let Some(edit_state) = &self.edit_state {
1121                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1122                    new_entry_parent_id = Some(edit_state.entry_id);
1123                    new_entry_kind = if edit_state.is_dir {
1124                        EntryKind::Dir
1125                    } else {
1126                        EntryKind::File(Default::default())
1127                    };
1128                }
1129            }
1130
1131            let mut visible_worktree_entries = Vec::new();
1132            let mut entry_iter = snapshot.entries(true);
1133
1134            while let Some(entry) = entry_iter.entry() {
1135                visible_worktree_entries.push(entry.clone());
1136                if Some(entry.id) == new_entry_parent_id {
1137                    visible_worktree_entries.push(Entry {
1138                        id: NEW_ENTRY_ID,
1139                        kind: new_entry_kind,
1140                        path: entry.path.join("\0").into(),
1141                        inode: 0,
1142                        mtime: entry.mtime,
1143                        is_symlink: false,
1144                        is_ignored: false,
1145                        is_external: false,
1146                        git_status: entry.git_status,
1147                    });
1148                }
1149                if expanded_dir_ids.binary_search(&entry.id).is_err()
1150                    && entry_iter.advance_to_sibling()
1151                {
1152                    continue;
1153                }
1154                entry_iter.advance();
1155            }
1156
1157            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1158
1159            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1160                let mut components_a = entry_a.path.components().peekable();
1161                let mut components_b = entry_b.path.components().peekable();
1162                loop {
1163                    match (components_a.next(), components_b.next()) {
1164                        (Some(component_a), Some(component_b)) => {
1165                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1166                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1167                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1168                                let name_a =
1169                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1170                                let name_b =
1171                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1172                                name_a.cmp(&name_b)
1173                            });
1174                            if !ordering.is_eq() {
1175                                return ordering;
1176                            }
1177                        }
1178                        (Some(_), None) => break Ordering::Greater,
1179                        (None, Some(_)) => break Ordering::Less,
1180                        (None, None) => break Ordering::Equal,
1181                    }
1182                }
1183            });
1184            self.visible_entries
1185                .push((worktree_id, visible_worktree_entries));
1186        }
1187
1188        if let Some((worktree_id, entry_id)) = new_selected_entry {
1189            self.selection = Some(Selection {
1190                worktree_id,
1191                entry_id,
1192            });
1193        }
1194    }
1195
1196    fn expand_entry(
1197        &mut self,
1198        worktree_id: WorktreeId,
1199        entry_id: ProjectEntryId,
1200        cx: &mut ViewContext<Self>,
1201    ) {
1202        self.project.update(cx, |project, cx| {
1203            if let Some((worktree, expanded_dir_ids)) = project
1204                .worktree_for_id(worktree_id, cx)
1205                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1206            {
1207                project.expand_entry(worktree_id, entry_id, cx);
1208                let worktree = worktree.read(cx);
1209
1210                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1211                    loop {
1212                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1213                            expanded_dir_ids.insert(ix, entry.id);
1214                        }
1215
1216                        if let Some(parent_entry) =
1217                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1218                        {
1219                            entry = parent_entry;
1220                        } else {
1221                            break;
1222                        }
1223                    }
1224                }
1225            }
1226        });
1227    }
1228
1229    fn for_each_visible_entry(
1230        &self,
1231        range: Range<usize>,
1232        cx: &mut ViewContext<ProjectPanel>,
1233        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1234    ) {
1235        let mut ix = 0;
1236        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1237            if ix >= range.end {
1238                return;
1239            }
1240
1241            if ix + visible_worktree_entries.len() <= range.start {
1242                ix += visible_worktree_entries.len();
1243                continue;
1244            }
1245
1246            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1247            let (git_status_setting, show_file_icons, show_folder_icons) = {
1248                let settings = ProjectPanelSettings::get_global(cx);
1249                (
1250                    settings.git_status,
1251                    settings.file_icons,
1252                    settings.folder_icons,
1253                )
1254            };
1255            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1256                let snapshot = worktree.read(cx).snapshot();
1257                let root_name = OsStr::new(snapshot.root_name());
1258                let expanded_entry_ids = self
1259                    .expanded_dir_ids
1260                    .get(&snapshot.id())
1261                    .map(Vec::as_slice)
1262                    .unwrap_or(&[]);
1263
1264                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1265                for entry in visible_worktree_entries[entry_range].iter() {
1266                    let status = git_status_setting.then(|| entry.git_status).flatten();
1267                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1268                    let icon = match entry.kind {
1269                        EntryKind::File(_) => {
1270                            if show_file_icons {
1271                                Some(FileAssociations::get_icon(&entry.path, cx))
1272                            } else {
1273                                None
1274                            }
1275                        }
1276                        _ => {
1277                            if show_folder_icons {
1278                                Some(FileAssociations::get_folder_icon(is_expanded, cx))
1279                            } else {
1280                                Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1281                            }
1282                        }
1283                    };
1284
1285                    let mut details = EntryDetails {
1286                        filename: entry
1287                            .path
1288                            .file_name()
1289                            .unwrap_or(root_name)
1290                            .to_string_lossy()
1291                            .to_string(),
1292                        icon,
1293                        path: entry.path.clone(),
1294                        depth: entry.path.components().count(),
1295                        kind: entry.kind,
1296                        is_ignored: entry.is_ignored,
1297                        is_expanded,
1298                        is_selected: self.selection.map_or(false, |e| {
1299                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1300                        }),
1301                        is_editing: false,
1302                        is_processing: false,
1303                        is_cut: self
1304                            .clipboard_entry
1305                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1306                        git_status: status,
1307                    };
1308
1309                    if let Some(edit_state) = &self.edit_state {
1310                        let is_edited_entry = if edit_state.is_new_entry {
1311                            entry.id == NEW_ENTRY_ID
1312                        } else {
1313                            entry.id == edit_state.entry_id
1314                        };
1315
1316                        if is_edited_entry {
1317                            if let Some(processing_filename) = &edit_state.processing_filename {
1318                                details.is_processing = true;
1319                                details.filename.clear();
1320                                details.filename.push_str(processing_filename);
1321                            } else {
1322                                if edit_state.is_new_entry {
1323                                    details.filename.clear();
1324                                }
1325                                details.is_editing = true;
1326                            }
1327                        }
1328                    }
1329
1330                    callback(entry.id, details, cx);
1331                }
1332            }
1333            ix = end_ix;
1334        }
1335    }
1336
1337    fn render_entry_visual_element(
1338        details: &EntryDetails,
1339        editor: Option<&View<Editor>>,
1340        padding: Pixels,
1341        cx: &mut ViewContext<Self>,
1342    ) -> Div {
1343        let show_editor = details.is_editing && !details.is_processing;
1344
1345        let theme = cx.theme();
1346        let filename_text_color = details
1347            .git_status
1348            .as_ref()
1349            .map(|status| match status {
1350                GitFileStatus::Added => theme.status().created,
1351                GitFileStatus::Modified => theme.status().modified,
1352                GitFileStatus::Conflict => theme.status().conflict,
1353            })
1354            .unwrap_or(theme.status().info);
1355
1356        h_stack()
1357            .child(if let Some(icon) = &details.icon {
1358                div().child(IconElement::from_path(icon.to_string()))
1359            } else {
1360                div()
1361            })
1362            .child(
1363                if let (Some(editor), true) = (editor, show_editor) {
1364                    div().w_full().child(editor.clone())
1365                } else {
1366                    div()
1367                        .text_color(filename_text_color)
1368                        .child(Label::new(details.filename.clone()))
1369                }
1370                .ml_1(),
1371            )
1372            .pl(padding)
1373    }
1374
1375    fn render_entry(
1376        &self,
1377        entry_id: ProjectEntryId,
1378        details: EntryDetails,
1379        // dragged_entry_destination: &mut Option<Arc<Path>>,
1380        cx: &mut ViewContext<Self>,
1381    ) -> Stateful<Div> {
1382        let kind = details.kind;
1383        let settings = ProjectPanelSettings::get_global(cx);
1384        const INDENT_SIZE: Pixels = px(16.0);
1385        let padding = INDENT_SIZE + details.depth as f32 * px(settings.indent_size);
1386        let show_editor = details.is_editing && !details.is_processing;
1387        let is_selected = self
1388            .selection
1389            .map_or(false, |selection| selection.entry_id == entry_id);
1390
1391        Self::render_entry_visual_element(&details, Some(&self.filename_editor), padding, cx)
1392            .id(entry_id.to_proto() as usize)
1393            .w_full()
1394            .cursor_pointer()
1395            .when(is_selected, |this| {
1396                this.bg(cx.theme().colors().element_selected)
1397            })
1398            .hover(|style| style.bg(cx.theme().colors().element_hover))
1399            .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1400                if !show_editor {
1401                    if kind.is_dir() {
1402                        this.toggle_expanded(entry_id, cx);
1403                    } else {
1404                        if event.down.modifiers.command {
1405                            this.split_entry(entry_id, cx);
1406                        } else {
1407                            this.open_entry(entry_id, event.up.click_count > 1, cx);
1408                        }
1409                    }
1410                }
1411            }))
1412            .on_mouse_down(
1413                MouseButton::Right,
1414                cx.listener(move |this, event: &MouseDownEvent, cx| {
1415                    this.deploy_context_menu(event.position, entry_id, cx);
1416                }),
1417            )
1418        // .on_drop::<ProjectEntryId>(|this, event, cx| {
1419        //     this.move_entry(
1420        //         *dragged_entry,
1421        //         entry_id,
1422        //         matches!(details.kind, EntryKind::File(_)),
1423        //         cx,
1424        //     );
1425        // })
1426    }
1427}
1428
1429impl Render for ProjectPanel {
1430    type Element = Focusable<Stateful<Div>>;
1431
1432    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1433        let has_worktree = self.visible_entries.len() != 0;
1434
1435        if has_worktree {
1436            div()
1437                .id("project-panel")
1438                .size_full()
1439                .key_context("ProjectPanel")
1440                .on_action(cx.listener(Self::select_next))
1441                .on_action(cx.listener(Self::select_prev))
1442                .on_action(cx.listener(Self::expand_selected_entry))
1443                .on_action(cx.listener(Self::collapse_selected_entry))
1444                .on_action(cx.listener(Self::collapse_all_entries))
1445                .on_action(cx.listener(Self::new_file))
1446                .on_action(cx.listener(Self::new_directory))
1447                .on_action(cx.listener(Self::rename))
1448                .on_action(cx.listener(Self::delete))
1449                .on_action(cx.listener(Self::confirm))
1450                .on_action(cx.listener(Self::open_file))
1451                .on_action(cx.listener(Self::cancel))
1452                .on_action(cx.listener(Self::cut))
1453                .on_action(cx.listener(Self::copy))
1454                .on_action(cx.listener(Self::copy_path))
1455                .on_action(cx.listener(Self::copy_relative_path))
1456                .on_action(cx.listener(Self::paste))
1457                .on_action(cx.listener(Self::reveal_in_finder))
1458                .on_action(cx.listener(Self::open_in_terminal))
1459                .on_action(cx.listener(Self::new_search_in_directory))
1460                .track_focus(&self.focus_handle)
1461                .child(
1462                    uniform_list(
1463                        cx.view().clone(),
1464                        "entries",
1465                        self.visible_entries
1466                            .iter()
1467                            .map(|(_, worktree_entries)| worktree_entries.len())
1468                            .sum(),
1469                        {
1470                            |this, range, cx| {
1471                                let mut items = Vec::new();
1472                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1473                                    items.push(this.render_entry(id, details, cx));
1474                                });
1475                                items
1476                            }
1477                        },
1478                    )
1479                    .size_full()
1480                    .track_scroll(self.list.clone()),
1481                )
1482        } else {
1483            v_stack()
1484                .id("empty-project_panel")
1485                .track_focus(&self.focus_handle)
1486        }
1487    }
1488}
1489
1490impl EventEmitter<Event> for ProjectPanel {}
1491
1492impl EventEmitter<PanelEvent> for ProjectPanel {}
1493
1494impl Panel for ProjectPanel {
1495    fn position(&self, cx: &WindowContext) -> DockPosition {
1496        match ProjectPanelSettings::get_global(cx).dock {
1497            ProjectPanelDockPosition::Left => DockPosition::Left,
1498            ProjectPanelDockPosition::Right => DockPosition::Right,
1499        }
1500    }
1501
1502    fn position_is_valid(&self, position: DockPosition) -> bool {
1503        matches!(position, DockPosition::Left | DockPosition::Right)
1504    }
1505
1506    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1507        settings::update_settings_file::<ProjectPanelSettings>(
1508            self.fs.clone(),
1509            cx,
1510            move |settings| {
1511                let dock = match position {
1512                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1513                    DockPosition::Right => ProjectPanelDockPosition::Right,
1514                };
1515                settings.dock = Some(dock);
1516            },
1517        );
1518    }
1519
1520    fn size(&self, cx: &WindowContext) -> f32 {
1521        self.width
1522            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1523    }
1524
1525    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1526        self.width = size;
1527        self.serialize(cx);
1528        cx.notify();
1529    }
1530
1531    fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1532        Some(ui::Icon::FileTree)
1533    }
1534
1535    fn toggle_action(&self) -> Box<dyn Action> {
1536        Box::new(ToggleFocus)
1537    }
1538
1539    fn has_focus(&self, _: &WindowContext) -> bool {
1540        self.has_focus
1541    }
1542
1543    fn persistent_name() -> &'static str {
1544        "Project Panel"
1545    }
1546}
1547
1548impl FocusableView for ProjectPanel {
1549    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1550        self.focus_handle.clone()
1551    }
1552}
1553
1554impl ClipboardEntry {
1555    fn is_cut(&self) -> bool {
1556        matches!(self, Self::Cut { .. })
1557    }
1558
1559    fn entry_id(&self) -> ProjectEntryId {
1560        match self {
1561            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1562                *entry_id
1563            }
1564        }
1565    }
1566
1567    fn worktree_id(&self) -> WorktreeId {
1568        match self {
1569            ClipboardEntry::Copied { worktree_id, .. }
1570            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1571        }
1572    }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577    use super::*;
1578    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1579    use pretty_assertions::assert_eq;
1580    use project::{project_settings::ProjectSettings, FakeFs};
1581    use serde_json::json;
1582    use settings::SettingsStore;
1583    use std::{
1584        collections::HashSet,
1585        path::{Path, PathBuf},
1586        sync::atomic::{self, AtomicUsize},
1587    };
1588    use workspace::AppState;
1589
1590    #[gpui::test]
1591    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1592        init_test(cx);
1593
1594        let fs = FakeFs::new(cx.executor().clone());
1595        fs.insert_tree(
1596            "/root1",
1597            json!({
1598                ".dockerignore": "",
1599                ".git": {
1600                    "HEAD": "",
1601                },
1602                "a": {
1603                    "0": { "q": "", "r": "", "s": "" },
1604                    "1": { "t": "", "u": "" },
1605                    "2": { "v": "", "w": "", "x": "", "y": "" },
1606                },
1607                "b": {
1608                    "3": { "Q": "" },
1609                    "4": { "R": "", "S": "", "T": "", "U": "" },
1610                },
1611                "C": {
1612                    "5": {},
1613                    "6": { "V": "", "W": "" },
1614                    "7": { "X": "" },
1615                    "8": { "Y": {}, "Z": "" }
1616                }
1617            }),
1618        )
1619        .await;
1620        fs.insert_tree(
1621            "/root2",
1622            json!({
1623                "d": {
1624                    "9": ""
1625                },
1626                "e": {}
1627            }),
1628        )
1629        .await;
1630
1631        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1632        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1633        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1634        let panel = workspace
1635            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1636            .unwrap();
1637        assert_eq!(
1638            visible_entries_as_strings(&panel, 0..50, cx),
1639            &[
1640                "v root1",
1641                "    > .git",
1642                "    > a",
1643                "    > b",
1644                "    > C",
1645                "      .dockerignore",
1646                "v root2",
1647                "    > d",
1648                "    > e",
1649            ]
1650        );
1651
1652        toggle_expand_dir(&panel, "root1/b", cx);
1653        assert_eq!(
1654            visible_entries_as_strings(&panel, 0..50, cx),
1655            &[
1656                "v root1",
1657                "    > .git",
1658                "    > a",
1659                "    v b  <== selected",
1660                "        > 3",
1661                "        > 4",
1662                "    > C",
1663                "      .dockerignore",
1664                "v root2",
1665                "    > d",
1666                "    > e",
1667            ]
1668        );
1669
1670        assert_eq!(
1671            visible_entries_as_strings(&panel, 6..9, cx),
1672            &[
1673                //
1674                "    > C",
1675                "      .dockerignore",
1676                "v root2",
1677            ]
1678        );
1679    }
1680
1681    #[gpui::test]
1682    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1683        init_test(cx);
1684        cx.update(|cx| {
1685            cx.update_global::<SettingsStore, _>(|store, cx| {
1686                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1687                    project_settings.file_scan_exclusions =
1688                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1689                });
1690            });
1691        });
1692
1693        let fs = FakeFs::new(cx.background_executor.clone());
1694        fs.insert_tree(
1695            "/root1",
1696            json!({
1697                ".dockerignore": "",
1698                ".git": {
1699                    "HEAD": "",
1700                },
1701                "a": {
1702                    "0": { "q": "", "r": "", "s": "" },
1703                    "1": { "t": "", "u": "" },
1704                    "2": { "v": "", "w": "", "x": "", "y": "" },
1705                },
1706                "b": {
1707                    "3": { "Q": "" },
1708                    "4": { "R": "", "S": "", "T": "", "U": "" },
1709                },
1710                "C": {
1711                    "5": {},
1712                    "6": { "V": "", "W": "" },
1713                    "7": { "X": "" },
1714                    "8": { "Y": {}, "Z": "" }
1715                }
1716            }),
1717        )
1718        .await;
1719        fs.insert_tree(
1720            "/root2",
1721            json!({
1722                "d": {
1723                    "4": ""
1724                },
1725                "e": {}
1726            }),
1727        )
1728        .await;
1729
1730        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1731        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1732        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1733        let panel = workspace
1734            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1735            .unwrap();
1736        assert_eq!(
1737            visible_entries_as_strings(&panel, 0..50, cx),
1738            &[
1739                "v root1",
1740                "    > a",
1741                "    > b",
1742                "    > C",
1743                "      .dockerignore",
1744                "v root2",
1745                "    > d",
1746                "    > e",
1747            ]
1748        );
1749
1750        toggle_expand_dir(&panel, "root1/b", cx);
1751        assert_eq!(
1752            visible_entries_as_strings(&panel, 0..50, cx),
1753            &[
1754                "v root1",
1755                "    > a",
1756                "    v b  <== selected",
1757                "        > 3",
1758                "    > C",
1759                "      .dockerignore",
1760                "v root2",
1761                "    > d",
1762                "    > e",
1763            ]
1764        );
1765
1766        toggle_expand_dir(&panel, "root2/d", cx);
1767        assert_eq!(
1768            visible_entries_as_strings(&panel, 0..50, cx),
1769            &[
1770                "v root1",
1771                "    > a",
1772                "    v b",
1773                "        > 3",
1774                "    > C",
1775                "      .dockerignore",
1776                "v root2",
1777                "    v d  <== selected",
1778                "    > e",
1779            ]
1780        );
1781
1782        toggle_expand_dir(&panel, "root2/e", cx);
1783        assert_eq!(
1784            visible_entries_as_strings(&panel, 0..50, cx),
1785            &[
1786                "v root1",
1787                "    > a",
1788                "    v b",
1789                "        > 3",
1790                "    > C",
1791                "      .dockerignore",
1792                "v root2",
1793                "    v d",
1794                "    v e  <== selected",
1795            ]
1796        );
1797    }
1798
1799    #[gpui::test(iterations = 30)]
1800    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1801        init_test(cx);
1802
1803        let fs = FakeFs::new(cx.executor().clone());
1804        fs.insert_tree(
1805            "/root1",
1806            json!({
1807                ".dockerignore": "",
1808                ".git": {
1809                    "HEAD": "",
1810                },
1811                "a": {
1812                    "0": { "q": "", "r": "", "s": "" },
1813                    "1": { "t": "", "u": "" },
1814                    "2": { "v": "", "w": "", "x": "", "y": "" },
1815                },
1816                "b": {
1817                    "3": { "Q": "" },
1818                    "4": { "R": "", "S": "", "T": "", "U": "" },
1819                },
1820                "C": {
1821                    "5": {},
1822                    "6": { "V": "", "W": "" },
1823                    "7": { "X": "" },
1824                    "8": { "Y": {}, "Z": "" }
1825                }
1826            }),
1827        )
1828        .await;
1829        fs.insert_tree(
1830            "/root2",
1831            json!({
1832                "d": {
1833                    "9": ""
1834                },
1835                "e": {}
1836            }),
1837        )
1838        .await;
1839
1840        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1841        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1842        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1843        let panel = workspace
1844            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1845            .unwrap();
1846
1847        select_path(&panel, "root1", cx);
1848        assert_eq!(
1849            visible_entries_as_strings(&panel, 0..10, cx),
1850            &[
1851                "v root1  <== selected",
1852                "    > .git",
1853                "    > a",
1854                "    > b",
1855                "    > C",
1856                "      .dockerignore",
1857                "v root2",
1858                "    > d",
1859                "    > e",
1860            ]
1861        );
1862
1863        // Add a file with the root folder selected. The filename editor is placed
1864        // before the first file in the root folder.
1865        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1866        panel.update(cx, |panel, cx| {
1867            assert!(panel.filename_editor.read(cx).is_focused(cx));
1868        });
1869        assert_eq!(
1870            visible_entries_as_strings(&panel, 0..10, cx),
1871            &[
1872                "v root1",
1873                "    > .git",
1874                "    > a",
1875                "    > b",
1876                "    > C",
1877                "      [EDITOR: '']  <== selected",
1878                "      .dockerignore",
1879                "v root2",
1880                "    > d",
1881                "    > e",
1882            ]
1883        );
1884
1885        let confirm = panel.update(cx, |panel, cx| {
1886            panel
1887                .filename_editor
1888                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1889            panel.confirm_edit(cx).unwrap()
1890        });
1891        assert_eq!(
1892            visible_entries_as_strings(&panel, 0..10, cx),
1893            &[
1894                "v root1",
1895                "    > .git",
1896                "    > a",
1897                "    > b",
1898                "    > C",
1899                "      [PROCESSING: 'the-new-filename']  <== selected",
1900                "      .dockerignore",
1901                "v root2",
1902                "    > d",
1903                "    > e",
1904            ]
1905        );
1906
1907        confirm.await.unwrap();
1908        assert_eq!(
1909            visible_entries_as_strings(&panel, 0..10, cx),
1910            &[
1911                "v root1",
1912                "    > .git",
1913                "    > a",
1914                "    > b",
1915                "    > C",
1916                "      .dockerignore",
1917                "      the-new-filename  <== selected",
1918                "v root2",
1919                "    > d",
1920                "    > e",
1921            ]
1922        );
1923
1924        select_path(&panel, "root1/b", cx);
1925        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1926        assert_eq!(
1927            visible_entries_as_strings(&panel, 0..10, cx),
1928            &[
1929                "v root1",
1930                "    > .git",
1931                "    > a",
1932                "    v b",
1933                "        > 3",
1934                "        > 4",
1935                "          [EDITOR: '']  <== selected",
1936                "    > C",
1937                "      .dockerignore",
1938                "      the-new-filename",
1939            ]
1940        );
1941
1942        panel
1943            .update(cx, |panel, cx| {
1944                panel
1945                    .filename_editor
1946                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1947                panel.confirm_edit(cx).unwrap()
1948            })
1949            .await
1950            .unwrap();
1951        assert_eq!(
1952            visible_entries_as_strings(&panel, 0..10, cx),
1953            &[
1954                "v root1",
1955                "    > .git",
1956                "    > a",
1957                "    v b",
1958                "        > 3",
1959                "        > 4",
1960                "          another-filename.txt  <== selected",
1961                "    > C",
1962                "      .dockerignore",
1963                "      the-new-filename",
1964            ]
1965        );
1966
1967        select_path(&panel, "root1/b/another-filename.txt", cx);
1968        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1969        assert_eq!(
1970            visible_entries_as_strings(&panel, 0..10, cx),
1971            &[
1972                "v root1",
1973                "    > .git",
1974                "    > a",
1975                "    v b",
1976                "        > 3",
1977                "        > 4",
1978                "          [EDITOR: 'another-filename.txt']  <== selected",
1979                "    > C",
1980                "      .dockerignore",
1981                "      the-new-filename",
1982            ]
1983        );
1984
1985        let confirm = panel.update(cx, |panel, cx| {
1986            panel.filename_editor.update(cx, |editor, cx| {
1987                let file_name_selections = editor.selections.all::<usize>(cx);
1988                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1989                let file_name_selection = &file_name_selections[0];
1990                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1991                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1992
1993                editor.set_text("a-different-filename.tar.gz", cx)
1994            });
1995            panel.confirm_edit(cx).unwrap()
1996        });
1997        assert_eq!(
1998            visible_entries_as_strings(&panel, 0..10, cx),
1999            &[
2000                "v root1",
2001                "    > .git",
2002                "    > a",
2003                "    v b",
2004                "        > 3",
2005                "        > 4",
2006                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2007                "    > C",
2008                "      .dockerignore",
2009                "      the-new-filename",
2010            ]
2011        );
2012
2013        confirm.await.unwrap();
2014        assert_eq!(
2015            visible_entries_as_strings(&panel, 0..10, cx),
2016            &[
2017                "v root1",
2018                "    > .git",
2019                "    > a",
2020                "    v b",
2021                "        > 3",
2022                "        > 4",
2023                "          a-different-filename.tar.gz  <== selected",
2024                "    > C",
2025                "      .dockerignore",
2026                "      the-new-filename",
2027            ]
2028        );
2029
2030        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2031        assert_eq!(
2032            visible_entries_as_strings(&panel, 0..10, cx),
2033            &[
2034                "v root1",
2035                "    > .git",
2036                "    > a",
2037                "    v b",
2038                "        > 3",
2039                "        > 4",
2040                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2041                "    > C",
2042                "      .dockerignore",
2043                "      the-new-filename",
2044            ]
2045        );
2046
2047        panel.update(cx, |panel, cx| {
2048            panel.filename_editor.update(cx, |editor, cx| {
2049                let file_name_selections = editor.selections.all::<usize>(cx);
2050                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2051                let file_name_selection = &file_name_selections[0];
2052                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2053                assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
2054
2055            });
2056            panel.cancel(&Cancel, cx)
2057        });
2058
2059        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2060        assert_eq!(
2061            visible_entries_as_strings(&panel, 0..10, cx),
2062            &[
2063                "v root1",
2064                "    > .git",
2065                "    > a",
2066                "    v b",
2067                "        > [EDITOR: '']  <== selected",
2068                "        > 3",
2069                "        > 4",
2070                "          a-different-filename.tar.gz",
2071                "    > C",
2072                "      .dockerignore",
2073            ]
2074        );
2075
2076        let confirm = panel.update(cx, |panel, cx| {
2077            panel
2078                .filename_editor
2079                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2080            panel.confirm_edit(cx).unwrap()
2081        });
2082        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2083        assert_eq!(
2084            visible_entries_as_strings(&panel, 0..10, cx),
2085            &[
2086                "v root1",
2087                "    > .git",
2088                "    > a",
2089                "    v b",
2090                "        > [PROCESSING: 'new-dir']",
2091                "        > 3  <== selected",
2092                "        > 4",
2093                "          a-different-filename.tar.gz",
2094                "    > C",
2095                "      .dockerignore",
2096            ]
2097        );
2098
2099        confirm.await.unwrap();
2100        assert_eq!(
2101            visible_entries_as_strings(&panel, 0..10, cx),
2102            &[
2103                "v root1",
2104                "    > .git",
2105                "    > a",
2106                "    v b",
2107                "        > 3  <== selected",
2108                "        > 4",
2109                "        > new-dir",
2110                "          a-different-filename.tar.gz",
2111                "    > C",
2112                "      .dockerignore",
2113            ]
2114        );
2115
2116        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2117        assert_eq!(
2118            visible_entries_as_strings(&panel, 0..10, cx),
2119            &[
2120                "v root1",
2121                "    > .git",
2122                "    > a",
2123                "    v b",
2124                "        > [EDITOR: '3']  <== selected",
2125                "        > 4",
2126                "        > new-dir",
2127                "          a-different-filename.tar.gz",
2128                "    > C",
2129                "      .dockerignore",
2130            ]
2131        );
2132
2133        // Dismiss the rename editor when it loses focus.
2134        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2135        assert_eq!(
2136            visible_entries_as_strings(&panel, 0..10, cx),
2137            &[
2138                "v root1",
2139                "    > .git",
2140                "    > a",
2141                "    v b",
2142                "        > 3  <== selected",
2143                "        > 4",
2144                "        > new-dir",
2145                "          a-different-filename.tar.gz",
2146                "    > C",
2147                "      .dockerignore",
2148            ]
2149        );
2150    }
2151
2152    #[gpui::test(iterations = 10)]
2153    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2154        init_test(cx);
2155
2156        let fs = FakeFs::new(cx.executor().clone());
2157        fs.insert_tree(
2158            "/root1",
2159            json!({
2160                ".dockerignore": "",
2161                ".git": {
2162                    "HEAD": "",
2163                },
2164                "a": {
2165                    "0": { "q": "", "r": "", "s": "" },
2166                    "1": { "t": "", "u": "" },
2167                    "2": { "v": "", "w": "", "x": "", "y": "" },
2168                },
2169                "b": {
2170                    "3": { "Q": "" },
2171                    "4": { "R": "", "S": "", "T": "", "U": "" },
2172                },
2173                "C": {
2174                    "5": {},
2175                    "6": { "V": "", "W": "" },
2176                    "7": { "X": "" },
2177                    "8": { "Y": {}, "Z": "" }
2178                }
2179            }),
2180        )
2181        .await;
2182        fs.insert_tree(
2183            "/root2",
2184            json!({
2185                "d": {
2186                    "9": ""
2187                },
2188                "e": {}
2189            }),
2190        )
2191        .await;
2192
2193        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2194        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2195        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2196        let panel = workspace
2197            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2198            .unwrap();
2199
2200        select_path(&panel, "root1", cx);
2201        assert_eq!(
2202            visible_entries_as_strings(&panel, 0..10, cx),
2203            &[
2204                "v root1  <== selected",
2205                "    > .git",
2206                "    > a",
2207                "    > b",
2208                "    > C",
2209                "      .dockerignore",
2210                "v root2",
2211                "    > d",
2212                "    > e",
2213            ]
2214        );
2215
2216        // Add a file with the root folder selected. The filename editor is placed
2217        // before the first file in the root folder.
2218        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2219        panel.update(cx, |panel, cx| {
2220            assert!(panel.filename_editor.read(cx).is_focused(cx));
2221        });
2222        assert_eq!(
2223            visible_entries_as_strings(&panel, 0..10, cx),
2224            &[
2225                "v root1",
2226                "    > .git",
2227                "    > a",
2228                "    > b",
2229                "    > C",
2230                "      [EDITOR: '']  <== selected",
2231                "      .dockerignore",
2232                "v root2",
2233                "    > d",
2234                "    > e",
2235            ]
2236        );
2237
2238        let confirm = panel.update(cx, |panel, cx| {
2239            panel.filename_editor.update(cx, |editor, cx| {
2240                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2241            });
2242            panel.confirm_edit(cx).unwrap()
2243        });
2244
2245        assert_eq!(
2246            visible_entries_as_strings(&panel, 0..10, cx),
2247            &[
2248                "v root1",
2249                "    > .git",
2250                "    > a",
2251                "    > b",
2252                "    > C",
2253                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2254                "      .dockerignore",
2255                "v root2",
2256                "    > d",
2257                "    > e",
2258            ]
2259        );
2260
2261        confirm.await.unwrap();
2262        assert_eq!(
2263            visible_entries_as_strings(&panel, 0..13, cx),
2264            &[
2265                "v root1",
2266                "    > .git",
2267                "    > a",
2268                "    > b",
2269                "    v bdir1",
2270                "        v dir2",
2271                "              the-new-filename  <== selected",
2272                "    > C",
2273                "      .dockerignore",
2274                "v root2",
2275                "    > d",
2276                "    > e",
2277            ]
2278        );
2279    }
2280
2281    #[gpui::test]
2282    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2283        init_test(cx);
2284
2285        let fs = FakeFs::new(cx.executor().clone());
2286        fs.insert_tree(
2287            "/root1",
2288            json!({
2289                "one.two.txt": "",
2290                "one.txt": ""
2291            }),
2292        )
2293        .await;
2294
2295        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2296        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2297        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2298        let panel = workspace
2299            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2300            .unwrap();
2301
2302        panel.update(cx, |panel, cx| {
2303            panel.select_next(&Default::default(), cx);
2304            panel.select_next(&Default::default(), cx);
2305        });
2306
2307        assert_eq!(
2308            visible_entries_as_strings(&panel, 0..50, cx),
2309            &[
2310                //
2311                "v root1",
2312                "      one.two.txt  <== selected",
2313                "      one.txt",
2314            ]
2315        );
2316
2317        // Regression test - file name is created correctly when
2318        // the copied file's name contains multiple dots.
2319        panel.update(cx, |panel, cx| {
2320            panel.copy(&Default::default(), cx);
2321            panel.paste(&Default::default(), cx);
2322        });
2323        cx.executor().run_until_parked();
2324
2325        assert_eq!(
2326            visible_entries_as_strings(&panel, 0..50, cx),
2327            &[
2328                //
2329                "v root1",
2330                "      one.two copy.txt",
2331                "      one.two.txt  <== selected",
2332                "      one.txt",
2333            ]
2334        );
2335
2336        panel.update(cx, |panel, cx| {
2337            panel.paste(&Default::default(), cx);
2338        });
2339        cx.executor().run_until_parked();
2340
2341        assert_eq!(
2342            visible_entries_as_strings(&panel, 0..50, cx),
2343            &[
2344                //
2345                "v root1",
2346                "      one.two copy 1.txt",
2347                "      one.two copy.txt",
2348                "      one.two.txt  <== selected",
2349                "      one.txt",
2350            ]
2351        );
2352    }
2353
2354    #[gpui::test]
2355    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2356        init_test_with_editor(cx);
2357
2358        let fs = FakeFs::new(cx.executor().clone());
2359        fs.insert_tree(
2360            "/src",
2361            json!({
2362                "test": {
2363                    "first.rs": "// First Rust file",
2364                    "second.rs": "// Second Rust file",
2365                    "third.rs": "// Third Rust file",
2366                }
2367            }),
2368        )
2369        .await;
2370
2371        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2372        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2373        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2374        let panel = workspace
2375            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2376            .unwrap();
2377
2378        toggle_expand_dir(&panel, "src/test", cx);
2379        select_path(&panel, "src/test/first.rs", cx);
2380        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2381        cx.executor().run_until_parked();
2382        assert_eq!(
2383            visible_entries_as_strings(&panel, 0..10, cx),
2384            &[
2385                "v src",
2386                "    v test",
2387                "          first.rs  <== selected",
2388                "          second.rs",
2389                "          third.rs"
2390            ]
2391        );
2392        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2393
2394        submit_deletion(&panel, cx);
2395        assert_eq!(
2396            visible_entries_as_strings(&panel, 0..10, cx),
2397            &[
2398                "v src",
2399                "    v test",
2400                "          second.rs",
2401                "          third.rs"
2402            ],
2403            "Project panel should have no deleted file, no other file is selected in it"
2404        );
2405        ensure_no_open_items_and_panes(&workspace, cx);
2406
2407        select_path(&panel, "src/test/second.rs", cx);
2408        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2409        cx.executor().run_until_parked();
2410        assert_eq!(
2411            visible_entries_as_strings(&panel, 0..10, cx),
2412            &[
2413                "v src",
2414                "    v test",
2415                "          second.rs  <== selected",
2416                "          third.rs"
2417            ]
2418        );
2419        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2420
2421        workspace
2422            .update(cx, |workspace, cx| {
2423                let active_items = workspace
2424                    .panes()
2425                    .iter()
2426                    .filter_map(|pane| pane.read(cx).active_item())
2427                    .collect::<Vec<_>>();
2428                assert_eq!(active_items.len(), 1);
2429                let open_editor = active_items
2430                    .into_iter()
2431                    .next()
2432                    .unwrap()
2433                    .downcast::<Editor>()
2434                    .expect("Open item should be an editor");
2435                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2436            })
2437            .unwrap();
2438        submit_deletion(&panel, cx);
2439        assert_eq!(
2440            visible_entries_as_strings(&panel, 0..10, cx),
2441            &["v src", "    v test", "          third.rs"],
2442            "Project panel should have no deleted file, with one last file remaining"
2443        );
2444        ensure_no_open_items_and_panes(&workspace, cx);
2445    }
2446
2447    #[gpui::test]
2448    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2449        init_test_with_editor(cx);
2450
2451        let fs = FakeFs::new(cx.executor().clone());
2452        fs.insert_tree(
2453            "/src",
2454            json!({
2455                "test": {
2456                    "first.rs": "// First Rust file",
2457                    "second.rs": "// Second Rust file",
2458                    "third.rs": "// Third Rust file",
2459                }
2460            }),
2461        )
2462        .await;
2463
2464        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2465        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2466        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2467        let panel = workspace
2468            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2469            .unwrap();
2470
2471        select_path(&panel, "src/", cx);
2472        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2473        cx.executor().run_until_parked();
2474        assert_eq!(
2475            visible_entries_as_strings(&panel, 0..10, cx),
2476            &[
2477                //
2478                "v src  <== selected",
2479                "    > test"
2480            ]
2481        );
2482        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2483        panel.update(cx, |panel, cx| {
2484            assert!(panel.filename_editor.read(cx).is_focused(cx));
2485        });
2486        assert_eq!(
2487            visible_entries_as_strings(&panel, 0..10, cx),
2488            &[
2489                //
2490                "v src",
2491                "    > [EDITOR: '']  <== selected",
2492                "    > test"
2493            ]
2494        );
2495        panel.update(cx, |panel, cx| {
2496            panel
2497                .filename_editor
2498                .update(cx, |editor, cx| editor.set_text("test", cx));
2499            assert!(
2500                panel.confirm_edit(cx).is_none(),
2501                "Should not allow to confirm on conflicting new directory name"
2502            )
2503        });
2504        assert_eq!(
2505            visible_entries_as_strings(&panel, 0..10, cx),
2506            &[
2507                //
2508                "v src",
2509                "    > test"
2510            ],
2511            "File list should be unchanged after failed folder create confirmation"
2512        );
2513
2514        select_path(&panel, "src/test/", cx);
2515        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2516        cx.executor().run_until_parked();
2517        assert_eq!(
2518            visible_entries_as_strings(&panel, 0..10, cx),
2519            &[
2520                //
2521                "v src",
2522                "    > test  <== selected"
2523            ]
2524        );
2525        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2526        panel.update(cx, |panel, cx| {
2527            assert!(panel.filename_editor.read(cx).is_focused(cx));
2528        });
2529        assert_eq!(
2530            visible_entries_as_strings(&panel, 0..10, cx),
2531            &[
2532                "v src",
2533                "    v test",
2534                "          [EDITOR: '']  <== selected",
2535                "          first.rs",
2536                "          second.rs",
2537                "          third.rs"
2538            ]
2539        );
2540        panel.update(cx, |panel, cx| {
2541            panel
2542                .filename_editor
2543                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2544            assert!(
2545                panel.confirm_edit(cx).is_none(),
2546                "Should not allow to confirm on conflicting new file name"
2547            )
2548        });
2549        assert_eq!(
2550            visible_entries_as_strings(&panel, 0..10, cx),
2551            &[
2552                "v src",
2553                "    v test",
2554                "          first.rs",
2555                "          second.rs",
2556                "          third.rs"
2557            ],
2558            "File list should be unchanged after failed file create confirmation"
2559        );
2560
2561        select_path(&panel, "src/test/first.rs", cx);
2562        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2563        cx.executor().run_until_parked();
2564        assert_eq!(
2565            visible_entries_as_strings(&panel, 0..10, cx),
2566            &[
2567                "v src",
2568                "    v test",
2569                "          first.rs  <== selected",
2570                "          second.rs",
2571                "          third.rs"
2572            ],
2573        );
2574        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2575        panel.update(cx, |panel, cx| {
2576            assert!(panel.filename_editor.read(cx).is_focused(cx));
2577        });
2578        assert_eq!(
2579            visible_entries_as_strings(&panel, 0..10, cx),
2580            &[
2581                "v src",
2582                "    v test",
2583                "          [EDITOR: 'first.rs']  <== selected",
2584                "          second.rs",
2585                "          third.rs"
2586            ]
2587        );
2588        panel.update(cx, |panel, cx| {
2589            panel
2590                .filename_editor
2591                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2592            assert!(
2593                panel.confirm_edit(cx).is_none(),
2594                "Should not allow to confirm on conflicting file rename"
2595            )
2596        });
2597        assert_eq!(
2598            visible_entries_as_strings(&panel, 0..10, cx),
2599            &[
2600                "v src",
2601                "    v test",
2602                "          first.rs  <== selected",
2603                "          second.rs",
2604                "          third.rs"
2605            ],
2606            "File list should be unchanged after failed rename confirmation"
2607        );
2608    }
2609
2610    #[gpui::test]
2611    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2612        init_test_with_editor(cx);
2613
2614        let fs = FakeFs::new(cx.executor().clone());
2615        fs.insert_tree(
2616            "/src",
2617            json!({
2618                "test": {
2619                    "first.rs": "// First Rust file",
2620                    "second.rs": "// Second Rust file",
2621                    "third.rs": "// Third Rust file",
2622                }
2623            }),
2624        )
2625        .await;
2626
2627        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2628        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2629        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2630        let panel = workspace
2631            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2632            .unwrap();
2633
2634        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2635        let _subscription = panel.update(cx, |_, cx| {
2636            let subcription_count = Arc::clone(&new_search_events_count);
2637            let view = cx.view().clone();
2638            cx.subscribe(&view, move |_, _, event, _| {
2639                if matches!(event, Event::NewSearchInDirectory { .. }) {
2640                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2641                }
2642            })
2643        });
2644
2645        toggle_expand_dir(&panel, "src/test", cx);
2646        select_path(&panel, "src/test/first.rs", cx);
2647        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2648        cx.executor().run_until_parked();
2649        assert_eq!(
2650            visible_entries_as_strings(&panel, 0..10, cx),
2651            &[
2652                "v src",
2653                "    v test",
2654                "          first.rs  <== selected",
2655                "          second.rs",
2656                "          third.rs"
2657            ]
2658        );
2659        panel.update(cx, |panel, cx| {
2660            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2661        });
2662        assert_eq!(
2663            new_search_events_count.load(atomic::Ordering::SeqCst),
2664            0,
2665            "Should not trigger new search in directory when called on a file"
2666        );
2667
2668        select_path(&panel, "src/test", cx);
2669        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2670        cx.executor().run_until_parked();
2671        assert_eq!(
2672            visible_entries_as_strings(&panel, 0..10, cx),
2673            &[
2674                "v src",
2675                "    v test  <== selected",
2676                "          first.rs",
2677                "          second.rs",
2678                "          third.rs"
2679            ]
2680        );
2681        panel.update(cx, |panel, cx| {
2682            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2683        });
2684        assert_eq!(
2685            new_search_events_count.load(atomic::Ordering::SeqCst),
2686            1,
2687            "Should trigger new search in directory when called on a directory"
2688        );
2689    }
2690
2691    #[gpui::test]
2692    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2693        init_test_with_editor(cx);
2694
2695        let fs = FakeFs::new(cx.executor().clone());
2696        fs.insert_tree(
2697            "/project_root",
2698            json!({
2699                "dir_1": {
2700                    "nested_dir": {
2701                        "file_a.py": "# File contents",
2702                        "file_b.py": "# File contents",
2703                        "file_c.py": "# File contents",
2704                    },
2705                    "file_1.py": "# File contents",
2706                    "file_2.py": "# File contents",
2707                    "file_3.py": "# File contents",
2708                },
2709                "dir_2": {
2710                    "file_1.py": "# File contents",
2711                    "file_2.py": "# File contents",
2712                    "file_3.py": "# File contents",
2713                }
2714            }),
2715        )
2716        .await;
2717
2718        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2719        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2720        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2721        let panel = workspace
2722            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2723            .unwrap();
2724
2725        panel.update(cx, |panel, cx| {
2726            panel.collapse_all_entries(&CollapseAllEntries, cx)
2727        });
2728        cx.executor().run_until_parked();
2729        assert_eq!(
2730            visible_entries_as_strings(&panel, 0..10, cx),
2731            &["v project_root", "    > dir_1", "    > dir_2",]
2732        );
2733
2734        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2735        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2736        cx.executor().run_until_parked();
2737        assert_eq!(
2738            visible_entries_as_strings(&panel, 0..10, cx),
2739            &[
2740                "v project_root",
2741                "    v dir_1  <== selected",
2742                "        > nested_dir",
2743                "          file_1.py",
2744                "          file_2.py",
2745                "          file_3.py",
2746                "    > dir_2",
2747            ]
2748        );
2749    }
2750
2751    #[gpui::test]
2752    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2753        init_test(cx);
2754
2755        let fs = FakeFs::new(cx.executor().clone());
2756        fs.as_fake().insert_tree("/root", json!({})).await;
2757        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2758        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2759        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2760        let panel = workspace
2761            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2762            .unwrap();
2763
2764        // Make a new buffer with no backing file
2765        workspace
2766            .update(cx, |workspace, cx| {
2767                Editor::new_file(workspace, &Default::default(), cx)
2768            })
2769            .unwrap();
2770
2771        // "Save as"" the buffer, creating a new backing file for it
2772        let save_task = workspace
2773            .update(cx, |workspace, cx| {
2774                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2775            })
2776            .unwrap();
2777
2778        cx.executor().run_until_parked();
2779        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2780        save_task.await.unwrap();
2781
2782        // Rename the file
2783        select_path(&panel, "root/new", cx);
2784        assert_eq!(
2785            visible_entries_as_strings(&panel, 0..10, cx),
2786            &["v root", "      new  <== selected"]
2787        );
2788        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2789        panel.update(cx, |panel, cx| {
2790            panel
2791                .filename_editor
2792                .update(cx, |editor, cx| editor.set_text("newer", cx));
2793        });
2794        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2795
2796        cx.executor().run_until_parked();
2797        assert_eq!(
2798            visible_entries_as_strings(&panel, 0..10, cx),
2799            &["v root", "      newer  <== selected"]
2800        );
2801
2802        workspace
2803            .update(cx, |workspace, cx| {
2804                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2805            })
2806            .unwrap()
2807            .await
2808            .unwrap();
2809
2810        cx.executor().run_until_parked();
2811        // assert that saving the file doesn't restore "new"
2812        assert_eq!(
2813            visible_entries_as_strings(&panel, 0..10, cx),
2814            &["v root", "      newer  <== selected"]
2815        );
2816    }
2817
2818    fn toggle_expand_dir(
2819        panel: &View<ProjectPanel>,
2820        path: impl AsRef<Path>,
2821        cx: &mut VisualTestContext,
2822    ) {
2823        let path = path.as_ref();
2824        panel.update(cx, |panel, cx| {
2825            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2826                let worktree = worktree.read(cx);
2827                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2828                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2829                    panel.toggle_expanded(entry_id, cx);
2830                    return;
2831                }
2832            }
2833            panic!("no worktree for path {:?}", path);
2834        });
2835    }
2836
2837    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2838        let path = path.as_ref();
2839        panel.update(cx, |panel, cx| {
2840            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2841                let worktree = worktree.read(cx);
2842                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2843                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2844                    panel.selection = Some(Selection {
2845                        worktree_id: worktree.id(),
2846                        entry_id,
2847                    });
2848                    return;
2849                }
2850            }
2851            panic!("no worktree for path {:?}", path);
2852        });
2853    }
2854
2855    fn visible_entries_as_strings(
2856        panel: &View<ProjectPanel>,
2857        range: Range<usize>,
2858        cx: &mut VisualTestContext,
2859    ) -> Vec<String> {
2860        let mut result = Vec::new();
2861        let mut project_entries = HashSet::new();
2862        let mut has_editor = false;
2863
2864        panel.update(cx, |panel, cx| {
2865            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2866                if details.is_editing {
2867                    assert!(!has_editor, "duplicate editor entry");
2868                    has_editor = true;
2869                } else {
2870                    assert!(
2871                        project_entries.insert(project_entry),
2872                        "duplicate project entry {:?} {:?}",
2873                        project_entry,
2874                        details
2875                    );
2876                }
2877
2878                let indent = "    ".repeat(details.depth);
2879                let icon = if details.kind.is_dir() {
2880                    if details.is_expanded {
2881                        "v "
2882                    } else {
2883                        "> "
2884                    }
2885                } else {
2886                    "  "
2887                };
2888                let name = if details.is_editing {
2889                    format!("[EDITOR: '{}']", details.filename)
2890                } else if details.is_processing {
2891                    format!("[PROCESSING: '{}']", details.filename)
2892                } else {
2893                    details.filename.clone()
2894                };
2895                let selected = if details.is_selected {
2896                    "  <== selected"
2897                } else {
2898                    ""
2899                };
2900                result.push(format!("{indent}{icon}{name}{selected}"));
2901            });
2902        });
2903
2904        result
2905    }
2906
2907    fn init_test(cx: &mut TestAppContext) {
2908        cx.update(|cx| {
2909            let settings_store = SettingsStore::test(cx);
2910            cx.set_global(settings_store);
2911            init_settings(cx);
2912            theme::init(theme::LoadThemes::JustBase, cx);
2913            language::init(cx);
2914            editor::init_settings(cx);
2915            crate::init((), cx);
2916            workspace::init_settings(cx);
2917            client::init_settings(cx);
2918            Project::init_settings(cx);
2919
2920            cx.update_global::<SettingsStore, _>(|store, cx| {
2921                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2922                    project_settings.file_scan_exclusions = Some(Vec::new());
2923                });
2924            });
2925        });
2926    }
2927
2928    fn init_test_with_editor(cx: &mut TestAppContext) {
2929        cx.update(|cx| {
2930            let app_state = AppState::test(cx);
2931            theme::init(theme::LoadThemes::JustBase, cx);
2932            init_settings(cx);
2933            language::init(cx);
2934            editor::init(cx);
2935            crate::init((), cx);
2936            workspace::init(app_state.clone(), cx);
2937            Project::init_settings(cx);
2938        });
2939    }
2940
2941    fn ensure_single_file_is_opened(
2942        window: &WindowHandle<Workspace>,
2943        expected_path: &str,
2944        cx: &mut TestAppContext,
2945    ) {
2946        window
2947            .update(cx, |workspace, cx| {
2948                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2949                assert_eq!(worktrees.len(), 1);
2950                let worktree_id = worktrees[0].read(cx).id();
2951
2952                let open_project_paths = workspace
2953                    .panes()
2954                    .iter()
2955                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2956                    .collect::<Vec<_>>();
2957                assert_eq!(
2958                    open_project_paths,
2959                    vec![ProjectPath {
2960                        worktree_id,
2961                        path: Arc::from(Path::new(expected_path))
2962                    }],
2963                    "Should have opened file, selected in project panel"
2964                );
2965            })
2966            .unwrap();
2967    }
2968
2969    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2970        assert!(
2971            !cx.has_pending_prompt(),
2972            "Should have no prompts before the deletion"
2973        );
2974        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2975        assert!(
2976            cx.has_pending_prompt(),
2977            "Should have a prompt after the deletion"
2978        );
2979        cx.simulate_prompt_answer(0);
2980        assert!(
2981            !cx.has_pending_prompt(),
2982            "Should have no prompts after prompt was replied to"
2983        );
2984        cx.executor().run_until_parked();
2985    }
2986
2987    fn ensure_no_open_items_and_panes(
2988        workspace: &WindowHandle<Workspace>,
2989        cx: &mut VisualTestContext,
2990    ) {
2991        assert!(
2992            !cx.has_pending_prompt(),
2993            "Should have no prompts after deletion operation closes the file"
2994        );
2995        workspace
2996            .read_with(cx, |workspace, cx| {
2997                let open_project_paths = workspace
2998                    .panes()
2999                    .iter()
3000                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3001                    .collect::<Vec<_>>();
3002                assert!(
3003                    open_project_paths.is_empty(),
3004                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3005                );
3006            })
3007            .unwrap();
3008    }
3009}