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    KeyContext, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
  14    Styled, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView,
  15    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::{v_stack, IconElement, Label, ListItem};
  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        dbg!("odd");
 648        self.edit_state = None;
 649        self.update_visible_entries(None, cx);
 650        cx.focus(&self.focus_handle);
 651        cx.notify();
 652    }
 653
 654    fn open_entry(
 655        &mut self,
 656        entry_id: ProjectEntryId,
 657        focus_opened_item: bool,
 658        cx: &mut ViewContext<Self>,
 659    ) {
 660        cx.emit(Event::OpenedEntry {
 661            entry_id,
 662            focus_opened_item,
 663        });
 664    }
 665
 666    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 667        cx.emit(Event::SplitEntry { entry_id });
 668    }
 669
 670    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 671        self.add_entry(false, cx)
 672    }
 673
 674    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 675        self.add_entry(true, cx)
 676    }
 677
 678    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 679        if let Some(Selection {
 680            worktree_id,
 681            entry_id,
 682        }) = self.selection
 683        {
 684            let directory_id;
 685            if let Some((worktree, expanded_dir_ids)) = self
 686                .project
 687                .read(cx)
 688                .worktree_for_id(worktree_id, cx)
 689                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 690            {
 691                let worktree = worktree.read(cx);
 692                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 693                    loop {
 694                        if entry.is_dir() {
 695                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 696                                expanded_dir_ids.insert(ix, entry.id);
 697                            }
 698                            directory_id = entry.id;
 699                            break;
 700                        } else {
 701                            if let Some(parent_path) = entry.path.parent() {
 702                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 703                                    entry = parent_entry;
 704                                    continue;
 705                                }
 706                            }
 707                            return;
 708                        }
 709                    }
 710                } else {
 711                    return;
 712                };
 713            } else {
 714                return;
 715            };
 716
 717            self.edit_state = Some(EditState {
 718                worktree_id,
 719                entry_id: directory_id,
 720                is_new_entry: true,
 721                is_dir,
 722                processing_filename: None,
 723            });
 724            self.filename_editor.update(cx, |editor, cx| {
 725                editor.clear(cx);
 726                editor.focus(cx);
 727            });
 728            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 729            self.autoscroll(cx);
 730            cx.notify();
 731        }
 732    }
 733
 734    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 735        if let Some(Selection {
 736            worktree_id,
 737            entry_id,
 738        }) = self.selection
 739        {
 740            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 741                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 742                    self.edit_state = Some(EditState {
 743                        worktree_id,
 744                        entry_id,
 745                        is_new_entry: false,
 746                        is_dir: entry.is_dir(),
 747                        processing_filename: None,
 748                    });
 749                    let file_name = entry
 750                        .path
 751                        .file_name()
 752                        .map(|s| s.to_string_lossy())
 753                        .unwrap_or_default()
 754                        .to_string();
 755                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 756                    let selection_end =
 757                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 758                    self.filename_editor.update(cx, |editor, cx| {
 759                        editor.set_text(file_name, cx);
 760                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 761                            s.select_ranges([0..selection_end])
 762                        });
 763                        editor.focus(cx);
 764                    });
 765                    self.update_visible_entries(None, cx);
 766                    self.autoscroll(cx);
 767                    cx.notify();
 768                }
 769            }
 770
 771            // cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 772            //     drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 773            // })
 774        }
 775    }
 776
 777    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
 778        maybe!({
 779            let Selection { entry_id, .. } = self.selection?;
 780            let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 781            let file_name = path.file_name()?;
 782
 783            let answer = cx.prompt(
 784                PromptLevel::Info,
 785                &format!("Delete {file_name:?}?"),
 786                &["Delete", "Cancel"],
 787            );
 788
 789            cx.spawn(|this, mut cx| async move {
 790                if answer.await != Ok(0) {
 791                    return Ok(());
 792                }
 793                this.update(&mut cx, |this, cx| {
 794                    this.project
 795                        .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 796                        .ok_or_else(|| anyhow!("no such entry"))
 797                })??
 798                .await
 799            })
 800            .detach_and_log_err(cx);
 801            Some(())
 802        });
 803    }
 804
 805    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 806        if let Some(selection) = self.selection {
 807            let (mut worktree_ix, mut entry_ix, _) =
 808                self.index_for_selection(selection).unwrap_or_default();
 809            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 810                if entry_ix + 1 < worktree_entries.len() {
 811                    entry_ix += 1;
 812                } else {
 813                    worktree_ix += 1;
 814                    entry_ix = 0;
 815                }
 816            }
 817
 818            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 819                if let Some(entry) = worktree_entries.get(entry_ix) {
 820                    self.selection = Some(Selection {
 821                        worktree_id: *worktree_id,
 822                        entry_id: entry.id,
 823                    });
 824                    self.autoscroll(cx);
 825                    cx.notify();
 826                }
 827            }
 828        } else {
 829            self.select_first(cx);
 830        }
 831    }
 832
 833    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 834        let worktree = self
 835            .visible_entries
 836            .first()
 837            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 838        if let Some(worktree) = worktree {
 839            let worktree = worktree.read(cx);
 840            let worktree_id = worktree.id();
 841            if let Some(root_entry) = worktree.root_entry() {
 842                self.selection = Some(Selection {
 843                    worktree_id,
 844                    entry_id: root_entry.id,
 845                });
 846                self.autoscroll(cx);
 847                cx.notify();
 848            }
 849        }
 850    }
 851
 852    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 853        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 854            self.list.scroll_to_item(index);
 855            cx.notify();
 856        }
 857    }
 858
 859    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 860        if let Some((worktree, entry)) = self.selected_entry(cx) {
 861            self.clipboard_entry = Some(ClipboardEntry::Cut {
 862                worktree_id: worktree.id(),
 863                entry_id: entry.id,
 864            });
 865            cx.notify();
 866        }
 867    }
 868
 869    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 870        if let Some((worktree, entry)) = self.selected_entry(cx) {
 871            self.clipboard_entry = Some(ClipboardEntry::Copied {
 872                worktree_id: worktree.id(),
 873                entry_id: entry.id,
 874            });
 875            cx.notify();
 876        }
 877    }
 878
 879    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
 880        maybe!({
 881            let (worktree, entry) = self.selected_entry(cx)?;
 882            let clipboard_entry = self.clipboard_entry?;
 883            if clipboard_entry.worktree_id() != worktree.id() {
 884                return None;
 885            }
 886
 887            let clipboard_entry_file_name = self
 888                .project
 889                .read(cx)
 890                .path_for_entry(clipboard_entry.entry_id(), cx)?
 891                .path
 892                .file_name()?
 893                .to_os_string();
 894
 895            let mut new_path = entry.path.to_path_buf();
 896            if entry.is_file() {
 897                new_path.pop();
 898            }
 899
 900            new_path.push(&clipboard_entry_file_name);
 901            let extension = new_path.extension().map(|e| e.to_os_string());
 902            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 903            let mut ix = 0;
 904            while worktree.entry_for_path(&new_path).is_some() {
 905                new_path.pop();
 906
 907                let mut new_file_name = file_name_without_extension.to_os_string();
 908                new_file_name.push(" copy");
 909                if ix > 0 {
 910                    new_file_name.push(format!(" {}", ix));
 911                }
 912                if let Some(extension) = extension.as_ref() {
 913                    new_file_name.push(".");
 914                    new_file_name.push(extension);
 915                }
 916
 917                new_path.push(new_file_name);
 918                ix += 1;
 919            }
 920
 921            if clipboard_entry.is_cut() {
 922                if let Some(task) = self.project.update(cx, |project, cx| {
 923                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 924                }) {
 925                    task.detach_and_log_err(cx);
 926                }
 927            } else if let Some(task) = self.project.update(cx, |project, cx| {
 928                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 929            }) {
 930                task.detach_and_log_err(cx);
 931            }
 932
 933            Some(())
 934        });
 935    }
 936
 937    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 938        if let Some((worktree, entry)) = self.selected_entry(cx) {
 939            cx.write_to_clipboard(ClipboardItem::new(
 940                worktree
 941                    .abs_path()
 942                    .join(&entry.path)
 943                    .to_string_lossy()
 944                    .to_string(),
 945            ));
 946        }
 947    }
 948
 949    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 950        if let Some((_, entry)) = self.selected_entry(cx) {
 951            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 952        }
 953    }
 954
 955    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 956        if let Some((worktree, entry)) = self.selected_entry(cx) {
 957            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 958        }
 959    }
 960
 961    fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
 962        todo!()
 963        // if let Some((worktree, entry)) = self.selected_entry(cx) {
 964        //     let window = cx.window();
 965        //     let view_id = cx.view_id();
 966        //     let path = worktree.abs_path().join(&entry.path);
 967
 968        //     cx.app_context()
 969        //         .spawn(|mut cx| async move {
 970        //             window.dispatch_action(
 971        //                 view_id,
 972        //                 &workspace::OpenTerminal {
 973        //                     working_directory: path,
 974        //                 },
 975        //                 &mut cx,
 976        //             );
 977        //         })
 978        //         .detach();
 979        // }
 980    }
 981
 982    pub fn new_search_in_directory(
 983        &mut self,
 984        _: &NewSearchInDirectory,
 985        cx: &mut ViewContext<Self>,
 986    ) {
 987        if let Some((_, entry)) = self.selected_entry(cx) {
 988            if entry.is_dir() {
 989                cx.emit(Event::NewSearchInDirectory {
 990                    dir_entry: entry.clone(),
 991                });
 992            }
 993        }
 994    }
 995
 996    // todo!()
 997    // fn move_entry(
 998    //     &mut self,
 999    //     entry_to_move: ProjectEntryId,
1000    //     destination: ProjectEntryId,
1001    //     destination_is_file: bool,
1002    //     cx: &mut ViewContext<Self>,
1003    // ) {
1004    //     let destination_worktree = self.project.update(cx, |project, cx| {
1005    //         let entry_path = project.path_for_entry(entry_to_move, cx)?;
1006    //         let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1007
1008    //         let mut destination_path = destination_entry_path.as_ref();
1009    //         if destination_is_file {
1010    //             destination_path = destination_path.parent()?;
1011    //         }
1012
1013    //         let mut new_path = destination_path.to_path_buf();
1014    //         new_path.push(entry_path.path.file_name()?);
1015    //         if new_path != entry_path.path.as_ref() {
1016    //             let task = project.rename_entry(entry_to_move, new_path, cx)?;
1017    //             cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1018    //         }
1019
1020    //         Some(project.worktree_id_for_entry(destination, cx)?)
1021    //     });
1022
1023    //     if let Some(destination_worktree) = destination_worktree {
1024    //         self.expand_entry(destination_worktree, destination, cx);
1025    //     }
1026    // }
1027
1028    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1029        let mut entry_index = 0;
1030        let mut visible_entries_index = 0;
1031        for (worktree_index, (worktree_id, worktree_entries)) in
1032            self.visible_entries.iter().enumerate()
1033        {
1034            if *worktree_id == selection.worktree_id {
1035                for entry in worktree_entries {
1036                    if entry.id == selection.entry_id {
1037                        return Some((worktree_index, entry_index, visible_entries_index));
1038                    } else {
1039                        visible_entries_index += 1;
1040                        entry_index += 1;
1041                    }
1042                }
1043                break;
1044            } else {
1045                visible_entries_index += worktree_entries.len();
1046            }
1047        }
1048        None
1049    }
1050
1051    pub fn selected_entry<'a>(
1052        &self,
1053        cx: &'a AppContext,
1054    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1055        let (worktree, entry) = self.selected_entry_handle(cx)?;
1056        Some((worktree.read(cx), entry))
1057    }
1058
1059    fn selected_entry_handle<'a>(
1060        &self,
1061        cx: &'a AppContext,
1062    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1063        let selection = self.selection?;
1064        let project = self.project.read(cx);
1065        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1066        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1067        Some((worktree, entry))
1068    }
1069
1070    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1071        let (worktree, entry) = self.selected_entry(cx)?;
1072        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1073
1074        for path in entry.path.ancestors() {
1075            let Some(entry) = worktree.entry_for_path(path) else {
1076                continue;
1077            };
1078            if entry.is_dir() {
1079                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1080                    expanded_dir_ids.insert(idx, entry.id);
1081                }
1082            }
1083        }
1084
1085        Some(())
1086    }
1087
1088    fn update_visible_entries(
1089        &mut self,
1090        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1091        cx: &mut ViewContext<Self>,
1092    ) {
1093        let project = self.project.read(cx);
1094        self.last_worktree_root_id = project
1095            .visible_worktrees(cx)
1096            .rev()
1097            .next()
1098            .and_then(|worktree| worktree.read(cx).root_entry())
1099            .map(|entry| entry.id);
1100
1101        self.visible_entries.clear();
1102        for worktree in project.visible_worktrees(cx) {
1103            let snapshot = worktree.read(cx).snapshot();
1104            let worktree_id = snapshot.id();
1105
1106            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1107                hash_map::Entry::Occupied(e) => e.into_mut(),
1108                hash_map::Entry::Vacant(e) => {
1109                    // The first time a worktree's root entry becomes available,
1110                    // mark that root entry as expanded.
1111                    if let Some(entry) = snapshot.root_entry() {
1112                        e.insert(vec![entry.id]).as_slice()
1113                    } else {
1114                        &[]
1115                    }
1116                }
1117            };
1118
1119            let mut new_entry_parent_id = None;
1120            let mut new_entry_kind = EntryKind::Dir;
1121            if let Some(edit_state) = &self.edit_state {
1122                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1123                    new_entry_parent_id = Some(edit_state.entry_id);
1124                    new_entry_kind = if edit_state.is_dir {
1125                        EntryKind::Dir
1126                    } else {
1127                        EntryKind::File(Default::default())
1128                    };
1129                }
1130            }
1131
1132            let mut visible_worktree_entries = Vec::new();
1133            let mut entry_iter = snapshot.entries(true);
1134
1135            while let Some(entry) = entry_iter.entry() {
1136                visible_worktree_entries.push(entry.clone());
1137                if Some(entry.id) == new_entry_parent_id {
1138                    visible_worktree_entries.push(Entry {
1139                        id: NEW_ENTRY_ID,
1140                        kind: new_entry_kind,
1141                        path: entry.path.join("\0").into(),
1142                        inode: 0,
1143                        mtime: entry.mtime,
1144                        is_symlink: false,
1145                        is_ignored: false,
1146                        is_external: false,
1147                        git_status: entry.git_status,
1148                    });
1149                }
1150                if expanded_dir_ids.binary_search(&entry.id).is_err()
1151                    && entry_iter.advance_to_sibling()
1152                {
1153                    continue;
1154                }
1155                entry_iter.advance();
1156            }
1157
1158            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1159
1160            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1161                let mut components_a = entry_a.path.components().peekable();
1162                let mut components_b = entry_b.path.components().peekable();
1163                loop {
1164                    match (components_a.next(), components_b.next()) {
1165                        (Some(component_a), Some(component_b)) => {
1166                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1167                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1168                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1169                                let name_a =
1170                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1171                                let name_b =
1172                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1173                                name_a.cmp(&name_b)
1174                            });
1175                            if !ordering.is_eq() {
1176                                return ordering;
1177                            }
1178                        }
1179                        (Some(_), None) => break Ordering::Greater,
1180                        (None, Some(_)) => break Ordering::Less,
1181                        (None, None) => break Ordering::Equal,
1182                    }
1183                }
1184            });
1185            self.visible_entries
1186                .push((worktree_id, visible_worktree_entries));
1187        }
1188
1189        if let Some((worktree_id, entry_id)) = new_selected_entry {
1190            self.selection = Some(Selection {
1191                worktree_id,
1192                entry_id,
1193            });
1194        }
1195    }
1196
1197    fn expand_entry(
1198        &mut self,
1199        worktree_id: WorktreeId,
1200        entry_id: ProjectEntryId,
1201        cx: &mut ViewContext<Self>,
1202    ) {
1203        self.project.update(cx, |project, cx| {
1204            if let Some((worktree, expanded_dir_ids)) = project
1205                .worktree_for_id(worktree_id, cx)
1206                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1207            {
1208                project.expand_entry(worktree_id, entry_id, cx);
1209                let worktree = worktree.read(cx);
1210
1211                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1212                    loop {
1213                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1214                            expanded_dir_ids.insert(ix, entry.id);
1215                        }
1216
1217                        if let Some(parent_entry) =
1218                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1219                        {
1220                            entry = parent_entry;
1221                        } else {
1222                            break;
1223                        }
1224                    }
1225                }
1226            }
1227        });
1228    }
1229
1230    fn for_each_visible_entry(
1231        &self,
1232        range: Range<usize>,
1233        cx: &mut ViewContext<ProjectPanel>,
1234        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1235    ) {
1236        let mut ix = 0;
1237        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1238            if ix >= range.end {
1239                return;
1240            }
1241
1242            if ix + visible_worktree_entries.len() <= range.start {
1243                ix += visible_worktree_entries.len();
1244                continue;
1245            }
1246
1247            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1248            let (git_status_setting, show_file_icons, show_folder_icons) = {
1249                let settings = ProjectPanelSettings::get_global(cx);
1250                (
1251                    settings.git_status,
1252                    settings.file_icons,
1253                    settings.folder_icons,
1254                )
1255            };
1256            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1257                let snapshot = worktree.read(cx).snapshot();
1258                let root_name = OsStr::new(snapshot.root_name());
1259                let expanded_entry_ids = self
1260                    .expanded_dir_ids
1261                    .get(&snapshot.id())
1262                    .map(Vec::as_slice)
1263                    .unwrap_or(&[]);
1264
1265                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1266                for entry in visible_worktree_entries[entry_range].iter() {
1267                    let status = git_status_setting.then(|| entry.git_status).flatten();
1268                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1269                    let icon = match entry.kind {
1270                        EntryKind::File(_) => {
1271                            if show_file_icons {
1272                                FileAssociations::get_icon(&entry.path, cx)
1273                            } else {
1274                                None
1275                            }
1276                        }
1277                        _ => {
1278                            if show_folder_icons {
1279                                FileAssociations::get_folder_icon(is_expanded, cx)
1280                            } else {
1281                                FileAssociations::get_chevron_icon(is_expanded, cx)
1282                            }
1283                        }
1284                    };
1285
1286                    let mut details = EntryDetails {
1287                        filename: entry
1288                            .path
1289                            .file_name()
1290                            .unwrap_or(root_name)
1291                            .to_string_lossy()
1292                            .to_string(),
1293                        icon,
1294                        path: entry.path.clone(),
1295                        depth: entry.path.components().count(),
1296                        kind: entry.kind,
1297                        is_ignored: entry.is_ignored,
1298                        is_expanded,
1299                        is_selected: self.selection.map_or(false, |e| {
1300                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1301                        }),
1302                        is_editing: false,
1303                        is_processing: false,
1304                        is_cut: self
1305                            .clipboard_entry
1306                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1307                        git_status: status,
1308                    };
1309
1310                    if let Some(edit_state) = &self.edit_state {
1311                        let is_edited_entry = if edit_state.is_new_entry {
1312                            entry.id == NEW_ENTRY_ID
1313                        } else {
1314                            entry.id == edit_state.entry_id
1315                        };
1316
1317                        if is_edited_entry {
1318                            if let Some(processing_filename) = &edit_state.processing_filename {
1319                                details.is_processing = true;
1320                                details.filename.clear();
1321                                details.filename.push_str(processing_filename);
1322                            } else {
1323                                if edit_state.is_new_entry {
1324                                    details.filename.clear();
1325                                }
1326                                details.is_editing = true;
1327                            }
1328                        }
1329                    }
1330
1331                    callback(entry.id, details, cx);
1332                }
1333            }
1334            ix = end_ix;
1335        }
1336    }
1337
1338    fn render_entry(
1339        &self,
1340        entry_id: ProjectEntryId,
1341        details: EntryDetails,
1342        // dragged_entry_destination: &mut Option<Arc<Path>>,
1343        cx: &mut ViewContext<Self>,
1344    ) -> ListItem {
1345        let kind = details.kind;
1346        let settings = ProjectPanelSettings::get_global(cx);
1347        let show_editor = details.is_editing && !details.is_processing;
1348        let is_selected = self
1349            .selection
1350            .map_or(false, |selection| selection.entry_id == entry_id);
1351
1352        let theme = cx.theme();
1353        let filename_text_color = details
1354            .git_status
1355            .as_ref()
1356            .map(|status| match status {
1357                GitFileStatus::Added => theme.status().created,
1358                GitFileStatus::Modified => theme.status().modified,
1359                GitFileStatus::Conflict => theme.status().conflict,
1360            })
1361            .unwrap_or(theme.status().info);
1362
1363        ListItem::new(entry_id.to_proto() as usize)
1364            .indent_level(details.depth)
1365            .indent_step_size(px(settings.indent_size))
1366            .selected(is_selected)
1367            .child(if let Some(icon) = &details.icon {
1368                div().child(IconElement::from_path(icon.to_string()))
1369            } else {
1370                div()
1371            })
1372            .child(
1373                if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1374                    div().w_full().child(editor.clone())
1375                } else {
1376                    div()
1377                        .text_color(filename_text_color)
1378                        .child(Label::new(details.filename.clone()))
1379                }
1380                .ml_1(),
1381            )
1382            .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1383                if !show_editor {
1384                    if kind.is_dir() {
1385                        this.toggle_expanded(entry_id, cx);
1386                    } else {
1387                        if event.down.modifiers.command {
1388                            this.split_entry(entry_id, cx);
1389                        } else {
1390                            this.open_entry(entry_id, event.up.click_count > 1, cx);
1391                        }
1392                    }
1393                }
1394            }))
1395            .on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
1396                this.deploy_context_menu(event.position, entry_id, cx);
1397            }))
1398        // .on_drop::<ProjectEntryId>(|this, event, cx| {
1399        //     this.move_entry(
1400        //         *dragged_entry,
1401        //         entry_id,
1402        //         matches!(details.kind, EntryKind::File(_)),
1403        //         cx,
1404        //     );
1405        // })
1406    }
1407
1408    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1409        let mut dispatch_context = KeyContext::default();
1410        dispatch_context.add("ProjectPanel");
1411        dispatch_context.add("menu");
1412
1413        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1414            "editing"
1415        } else {
1416            "not_editing"
1417        };
1418
1419        dispatch_context.add(identifier);
1420
1421        dispatch_context
1422    }
1423}
1424
1425impl Render for ProjectPanel {
1426    type Element = Focusable<Stateful<Div>>;
1427
1428    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1429        let has_worktree = self.visible_entries.len() != 0;
1430
1431        if has_worktree {
1432            div()
1433                .id("project-panel")
1434                .size_full()
1435                .relative()
1436                .key_context(self.dispatch_context(cx))
1437                .on_action(cx.listener(Self::select_next))
1438                .on_action(cx.listener(Self::select_prev))
1439                .on_action(cx.listener(Self::expand_selected_entry))
1440                .on_action(cx.listener(Self::collapse_selected_entry))
1441                .on_action(cx.listener(Self::collapse_all_entries))
1442                .on_action(cx.listener(Self::new_file))
1443                .on_action(cx.listener(Self::new_directory))
1444                .on_action(cx.listener(Self::rename))
1445                .on_action(cx.listener(Self::delete))
1446                .on_action(cx.listener(Self::confirm))
1447                .on_action(cx.listener(Self::open_file))
1448                .on_action(cx.listener(Self::cancel))
1449                .on_action(cx.listener(Self::cut))
1450                .on_action(cx.listener(Self::copy))
1451                .on_action(cx.listener(Self::copy_path))
1452                .on_action(cx.listener(Self::copy_relative_path))
1453                .on_action(cx.listener(Self::paste))
1454                .on_action(cx.listener(Self::reveal_in_finder))
1455                .on_action(cx.listener(Self::open_in_terminal))
1456                .on_action(cx.listener(Self::new_search_in_directory))
1457                .track_focus(&self.focus_handle)
1458                .child(
1459                    uniform_list(
1460                        cx.view().clone(),
1461                        "entries",
1462                        self.visible_entries
1463                            .iter()
1464                            .map(|(_, worktree_entries)| worktree_entries.len())
1465                            .sum(),
1466                        {
1467                            |this, range, cx| {
1468                                let mut items = Vec::new();
1469                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1470                                    items.push(this.render_entry(id, details, cx));
1471                                });
1472                                items
1473                            }
1474                        },
1475                    )
1476                    .size_full()
1477                    .track_scroll(self.list.clone()),
1478                )
1479        } else {
1480            v_stack()
1481                .id("empty-project_panel")
1482                .track_focus(&self.focus_handle)
1483        }
1484    }
1485}
1486
1487impl EventEmitter<Event> for ProjectPanel {}
1488
1489impl EventEmitter<PanelEvent> for ProjectPanel {}
1490
1491impl Panel for ProjectPanel {
1492    fn position(&self, cx: &WindowContext) -> DockPosition {
1493        match ProjectPanelSettings::get_global(cx).dock {
1494            ProjectPanelDockPosition::Left => DockPosition::Left,
1495            ProjectPanelDockPosition::Right => DockPosition::Right,
1496        }
1497    }
1498
1499    fn position_is_valid(&self, position: DockPosition) -> bool {
1500        matches!(position, DockPosition::Left | DockPosition::Right)
1501    }
1502
1503    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1504        settings::update_settings_file::<ProjectPanelSettings>(
1505            self.fs.clone(),
1506            cx,
1507            move |settings| {
1508                let dock = match position {
1509                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1510                    DockPosition::Right => ProjectPanelDockPosition::Right,
1511                };
1512                settings.dock = Some(dock);
1513            },
1514        );
1515    }
1516
1517    fn size(&self, cx: &WindowContext) -> f32 {
1518        self.width
1519            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1520    }
1521
1522    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1523        self.width = size;
1524        self.serialize(cx);
1525        cx.notify();
1526    }
1527
1528    fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1529        Some(ui::Icon::FileTree)
1530    }
1531
1532    fn toggle_action(&self) -> Box<dyn Action> {
1533        Box::new(ToggleFocus)
1534    }
1535
1536    fn has_focus(&self, _: &WindowContext) -> bool {
1537        self.has_focus
1538    }
1539
1540    fn persistent_name() -> &'static str {
1541        "Project Panel"
1542    }
1543}
1544
1545impl FocusableView for ProjectPanel {
1546    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1547        self.focus_handle.clone()
1548    }
1549}
1550
1551impl ClipboardEntry {
1552    fn is_cut(&self) -> bool {
1553        matches!(self, Self::Cut { .. })
1554    }
1555
1556    fn entry_id(&self) -> ProjectEntryId {
1557        match self {
1558            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1559                *entry_id
1560            }
1561        }
1562    }
1563
1564    fn worktree_id(&self) -> WorktreeId {
1565        match self {
1566            ClipboardEntry::Copied { worktree_id, .. }
1567            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1568        }
1569    }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1576    use pretty_assertions::assert_eq;
1577    use project::{project_settings::ProjectSettings, FakeFs};
1578    use serde_json::json;
1579    use settings::SettingsStore;
1580    use std::{
1581        collections::HashSet,
1582        path::{Path, PathBuf},
1583        sync::atomic::{self, AtomicUsize},
1584    };
1585    use workspace::AppState;
1586
1587    #[gpui::test]
1588    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1589        init_test(cx);
1590
1591        let fs = FakeFs::new(cx.executor().clone());
1592        fs.insert_tree(
1593            "/root1",
1594            json!({
1595                ".dockerignore": "",
1596                ".git": {
1597                    "HEAD": "",
1598                },
1599                "a": {
1600                    "0": { "q": "", "r": "", "s": "" },
1601                    "1": { "t": "", "u": "" },
1602                    "2": { "v": "", "w": "", "x": "", "y": "" },
1603                },
1604                "b": {
1605                    "3": { "Q": "" },
1606                    "4": { "R": "", "S": "", "T": "", "U": "" },
1607                },
1608                "C": {
1609                    "5": {},
1610                    "6": { "V": "", "W": "" },
1611                    "7": { "X": "" },
1612                    "8": { "Y": {}, "Z": "" }
1613                }
1614            }),
1615        )
1616        .await;
1617        fs.insert_tree(
1618            "/root2",
1619            json!({
1620                "d": {
1621                    "9": ""
1622                },
1623                "e": {}
1624            }),
1625        )
1626        .await;
1627
1628        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1629        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1630        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1631        let panel = workspace
1632            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1633            .unwrap();
1634        assert_eq!(
1635            visible_entries_as_strings(&panel, 0..50, cx),
1636            &[
1637                "v root1",
1638                "    > .git",
1639                "    > a",
1640                "    > b",
1641                "    > C",
1642                "      .dockerignore",
1643                "v root2",
1644                "    > d",
1645                "    > e",
1646            ]
1647        );
1648
1649        toggle_expand_dir(&panel, "root1/b", cx);
1650        assert_eq!(
1651            visible_entries_as_strings(&panel, 0..50, cx),
1652            &[
1653                "v root1",
1654                "    > .git",
1655                "    > a",
1656                "    v b  <== selected",
1657                "        > 3",
1658                "        > 4",
1659                "    > C",
1660                "      .dockerignore",
1661                "v root2",
1662                "    > d",
1663                "    > e",
1664            ]
1665        );
1666
1667        assert_eq!(
1668            visible_entries_as_strings(&panel, 6..9, cx),
1669            &[
1670                //
1671                "    > C",
1672                "      .dockerignore",
1673                "v root2",
1674            ]
1675        );
1676    }
1677
1678    #[gpui::test]
1679    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1680        init_test(cx);
1681        cx.update(|cx| {
1682            cx.update_global::<SettingsStore, _>(|store, cx| {
1683                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1684                    project_settings.file_scan_exclusions =
1685                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1686                });
1687            });
1688        });
1689
1690        let fs = FakeFs::new(cx.background_executor.clone());
1691        fs.insert_tree(
1692            "/root1",
1693            json!({
1694                ".dockerignore": "",
1695                ".git": {
1696                    "HEAD": "",
1697                },
1698                "a": {
1699                    "0": { "q": "", "r": "", "s": "" },
1700                    "1": { "t": "", "u": "" },
1701                    "2": { "v": "", "w": "", "x": "", "y": "" },
1702                },
1703                "b": {
1704                    "3": { "Q": "" },
1705                    "4": { "R": "", "S": "", "T": "", "U": "" },
1706                },
1707                "C": {
1708                    "5": {},
1709                    "6": { "V": "", "W": "" },
1710                    "7": { "X": "" },
1711                    "8": { "Y": {}, "Z": "" }
1712                }
1713            }),
1714        )
1715        .await;
1716        fs.insert_tree(
1717            "/root2",
1718            json!({
1719                "d": {
1720                    "4": ""
1721                },
1722                "e": {}
1723            }),
1724        )
1725        .await;
1726
1727        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1728        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1729        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1730        let panel = workspace
1731            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1732            .unwrap();
1733        assert_eq!(
1734            visible_entries_as_strings(&panel, 0..50, cx),
1735            &[
1736                "v root1",
1737                "    > a",
1738                "    > b",
1739                "    > C",
1740                "      .dockerignore",
1741                "v root2",
1742                "    > d",
1743                "    > e",
1744            ]
1745        );
1746
1747        toggle_expand_dir(&panel, "root1/b", cx);
1748        assert_eq!(
1749            visible_entries_as_strings(&panel, 0..50, cx),
1750            &[
1751                "v root1",
1752                "    > a",
1753                "    v b  <== selected",
1754                "        > 3",
1755                "    > C",
1756                "      .dockerignore",
1757                "v root2",
1758                "    > d",
1759                "    > e",
1760            ]
1761        );
1762
1763        toggle_expand_dir(&panel, "root2/d", cx);
1764        assert_eq!(
1765            visible_entries_as_strings(&panel, 0..50, cx),
1766            &[
1767                "v root1",
1768                "    > a",
1769                "    v b",
1770                "        > 3",
1771                "    > C",
1772                "      .dockerignore",
1773                "v root2",
1774                "    v d  <== selected",
1775                "    > e",
1776            ]
1777        );
1778
1779        toggle_expand_dir(&panel, "root2/e", cx);
1780        assert_eq!(
1781            visible_entries_as_strings(&panel, 0..50, cx),
1782            &[
1783                "v root1",
1784                "    > a",
1785                "    v b",
1786                "        > 3",
1787                "    > C",
1788                "      .dockerignore",
1789                "v root2",
1790                "    v d",
1791                "    v e  <== selected",
1792            ]
1793        );
1794    }
1795
1796    #[gpui::test(iterations = 30)]
1797    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1798        init_test(cx);
1799
1800        let fs = FakeFs::new(cx.executor().clone());
1801        fs.insert_tree(
1802            "/root1",
1803            json!({
1804                ".dockerignore": "",
1805                ".git": {
1806                    "HEAD": "",
1807                },
1808                "a": {
1809                    "0": { "q": "", "r": "", "s": "" },
1810                    "1": { "t": "", "u": "" },
1811                    "2": { "v": "", "w": "", "x": "", "y": "" },
1812                },
1813                "b": {
1814                    "3": { "Q": "" },
1815                    "4": { "R": "", "S": "", "T": "", "U": "" },
1816                },
1817                "C": {
1818                    "5": {},
1819                    "6": { "V": "", "W": "" },
1820                    "7": { "X": "" },
1821                    "8": { "Y": {}, "Z": "" }
1822                }
1823            }),
1824        )
1825        .await;
1826        fs.insert_tree(
1827            "/root2",
1828            json!({
1829                "d": {
1830                    "9": ""
1831                },
1832                "e": {}
1833            }),
1834        )
1835        .await;
1836
1837        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1838        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1839        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1840        let panel = workspace
1841            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1842            .unwrap();
1843
1844        select_path(&panel, "root1", cx);
1845        assert_eq!(
1846            visible_entries_as_strings(&panel, 0..10, cx),
1847            &[
1848                "v root1  <== selected",
1849                "    > .git",
1850                "    > a",
1851                "    > b",
1852                "    > C",
1853                "      .dockerignore",
1854                "v root2",
1855                "    > d",
1856                "    > e",
1857            ]
1858        );
1859
1860        // Add a file with the root folder selected. The filename editor is placed
1861        // before the first file in the root folder.
1862        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1863        panel.update(cx, |panel, cx| {
1864            assert!(panel.filename_editor.read(cx).is_focused(cx));
1865        });
1866        assert_eq!(
1867            visible_entries_as_strings(&panel, 0..10, cx),
1868            &[
1869                "v root1",
1870                "    > .git",
1871                "    > a",
1872                "    > b",
1873                "    > C",
1874                "      [EDITOR: '']  <== selected",
1875                "      .dockerignore",
1876                "v root2",
1877                "    > d",
1878                "    > e",
1879            ]
1880        );
1881
1882        let confirm = panel.update(cx, |panel, cx| {
1883            panel
1884                .filename_editor
1885                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1886            panel.confirm_edit(cx).unwrap()
1887        });
1888        assert_eq!(
1889            visible_entries_as_strings(&panel, 0..10, cx),
1890            &[
1891                "v root1",
1892                "    > .git",
1893                "    > a",
1894                "    > b",
1895                "    > C",
1896                "      [PROCESSING: 'the-new-filename']  <== selected",
1897                "      .dockerignore",
1898                "v root2",
1899                "    > d",
1900                "    > e",
1901            ]
1902        );
1903
1904        confirm.await.unwrap();
1905        assert_eq!(
1906            visible_entries_as_strings(&panel, 0..10, cx),
1907            &[
1908                "v root1",
1909                "    > .git",
1910                "    > a",
1911                "    > b",
1912                "    > C",
1913                "      .dockerignore",
1914                "      the-new-filename  <== selected",
1915                "v root2",
1916                "    > d",
1917                "    > e",
1918            ]
1919        );
1920
1921        select_path(&panel, "root1/b", cx);
1922        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1923        assert_eq!(
1924            visible_entries_as_strings(&panel, 0..10, cx),
1925            &[
1926                "v root1",
1927                "    > .git",
1928                "    > a",
1929                "    v b",
1930                "        > 3",
1931                "        > 4",
1932                "          [EDITOR: '']  <== selected",
1933                "    > C",
1934                "      .dockerignore",
1935                "      the-new-filename",
1936            ]
1937        );
1938
1939        panel
1940            .update(cx, |panel, cx| {
1941                panel
1942                    .filename_editor
1943                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1944                panel.confirm_edit(cx).unwrap()
1945            })
1946            .await
1947            .unwrap();
1948        assert_eq!(
1949            visible_entries_as_strings(&panel, 0..10, cx),
1950            &[
1951                "v root1",
1952                "    > .git",
1953                "    > a",
1954                "    v b",
1955                "        > 3",
1956                "        > 4",
1957                "          another-filename.txt  <== selected",
1958                "    > C",
1959                "      .dockerignore",
1960                "      the-new-filename",
1961            ]
1962        );
1963
1964        select_path(&panel, "root1/b/another-filename.txt", cx);
1965        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1966        assert_eq!(
1967            visible_entries_as_strings(&panel, 0..10, cx),
1968            &[
1969                "v root1",
1970                "    > .git",
1971                "    > a",
1972                "    v b",
1973                "        > 3",
1974                "        > 4",
1975                "          [EDITOR: 'another-filename.txt']  <== selected",
1976                "    > C",
1977                "      .dockerignore",
1978                "      the-new-filename",
1979            ]
1980        );
1981
1982        let confirm = panel.update(cx, |panel, cx| {
1983            panel.filename_editor.update(cx, |editor, cx| {
1984                let file_name_selections = editor.selections.all::<usize>(cx);
1985                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1986                let file_name_selection = &file_name_selections[0];
1987                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1988                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1989
1990                editor.set_text("a-different-filename.tar.gz", cx)
1991            });
1992            panel.confirm_edit(cx).unwrap()
1993        });
1994        assert_eq!(
1995            visible_entries_as_strings(&panel, 0..10, cx),
1996            &[
1997                "v root1",
1998                "    > .git",
1999                "    > a",
2000                "    v b",
2001                "        > 3",
2002                "        > 4",
2003                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2004                "    > C",
2005                "      .dockerignore",
2006                "      the-new-filename",
2007            ]
2008        );
2009
2010        confirm.await.unwrap();
2011        assert_eq!(
2012            visible_entries_as_strings(&panel, 0..10, cx),
2013            &[
2014                "v root1",
2015                "    > .git",
2016                "    > a",
2017                "    v b",
2018                "        > 3",
2019                "        > 4",
2020                "          a-different-filename.tar.gz  <== selected",
2021                "    > C",
2022                "      .dockerignore",
2023                "      the-new-filename",
2024            ]
2025        );
2026
2027        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2028        assert_eq!(
2029            visible_entries_as_strings(&panel, 0..10, cx),
2030            &[
2031                "v root1",
2032                "    > .git",
2033                "    > a",
2034                "    v b",
2035                "        > 3",
2036                "        > 4",
2037                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2038                "    > C",
2039                "      .dockerignore",
2040                "      the-new-filename",
2041            ]
2042        );
2043
2044        panel.update(cx, |panel, cx| {
2045            panel.filename_editor.update(cx, |editor, cx| {
2046                let file_name_selections = editor.selections.all::<usize>(cx);
2047                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2048                let file_name_selection = &file_name_selections[0];
2049                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2050                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..");
2051
2052            });
2053            panel.cancel(&Cancel, cx)
2054        });
2055
2056        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2057        assert_eq!(
2058            visible_entries_as_strings(&panel, 0..10, cx),
2059            &[
2060                "v root1",
2061                "    > .git",
2062                "    > a",
2063                "    v b",
2064                "        > [EDITOR: '']  <== selected",
2065                "        > 3",
2066                "        > 4",
2067                "          a-different-filename.tar.gz",
2068                "    > C",
2069                "      .dockerignore",
2070            ]
2071        );
2072
2073        let confirm = panel.update(cx, |panel, cx| {
2074            panel
2075                .filename_editor
2076                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2077            panel.confirm_edit(cx).unwrap()
2078        });
2079        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2080        assert_eq!(
2081            visible_entries_as_strings(&panel, 0..10, cx),
2082            &[
2083                "v root1",
2084                "    > .git",
2085                "    > a",
2086                "    v b",
2087                "        > [PROCESSING: 'new-dir']",
2088                "        > 3  <== selected",
2089                "        > 4",
2090                "          a-different-filename.tar.gz",
2091                "    > C",
2092                "      .dockerignore",
2093            ]
2094        );
2095
2096        confirm.await.unwrap();
2097        assert_eq!(
2098            visible_entries_as_strings(&panel, 0..10, cx),
2099            &[
2100                "v root1",
2101                "    > .git",
2102                "    > a",
2103                "    v b",
2104                "        > 3  <== selected",
2105                "        > 4",
2106                "        > new-dir",
2107                "          a-different-filename.tar.gz",
2108                "    > C",
2109                "      .dockerignore",
2110            ]
2111        );
2112
2113        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2114        assert_eq!(
2115            visible_entries_as_strings(&panel, 0..10, cx),
2116            &[
2117                "v root1",
2118                "    > .git",
2119                "    > a",
2120                "    v b",
2121                "        > [EDITOR: '3']  <== selected",
2122                "        > 4",
2123                "        > new-dir",
2124                "          a-different-filename.tar.gz",
2125                "    > C",
2126                "      .dockerignore",
2127            ]
2128        );
2129
2130        // Dismiss the rename editor when it loses focus.
2131        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2132        assert_eq!(
2133            visible_entries_as_strings(&panel, 0..10, cx),
2134            &[
2135                "v root1",
2136                "    > .git",
2137                "    > a",
2138                "    v b",
2139                "        > 3  <== selected",
2140                "        > 4",
2141                "        > new-dir",
2142                "          a-different-filename.tar.gz",
2143                "    > C",
2144                "      .dockerignore",
2145            ]
2146        );
2147    }
2148
2149    #[gpui::test(iterations = 10)]
2150    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2151        init_test(cx);
2152
2153        let fs = FakeFs::new(cx.executor().clone());
2154        fs.insert_tree(
2155            "/root1",
2156            json!({
2157                ".dockerignore": "",
2158                ".git": {
2159                    "HEAD": "",
2160                },
2161                "a": {
2162                    "0": { "q": "", "r": "", "s": "" },
2163                    "1": { "t": "", "u": "" },
2164                    "2": { "v": "", "w": "", "x": "", "y": "" },
2165                },
2166                "b": {
2167                    "3": { "Q": "" },
2168                    "4": { "R": "", "S": "", "T": "", "U": "" },
2169                },
2170                "C": {
2171                    "5": {},
2172                    "6": { "V": "", "W": "" },
2173                    "7": { "X": "" },
2174                    "8": { "Y": {}, "Z": "" }
2175                }
2176            }),
2177        )
2178        .await;
2179        fs.insert_tree(
2180            "/root2",
2181            json!({
2182                "d": {
2183                    "9": ""
2184                },
2185                "e": {}
2186            }),
2187        )
2188        .await;
2189
2190        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2191        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2192        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2193        let panel = workspace
2194            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2195            .unwrap();
2196
2197        select_path(&panel, "root1", cx);
2198        assert_eq!(
2199            visible_entries_as_strings(&panel, 0..10, cx),
2200            &[
2201                "v root1  <== selected",
2202                "    > .git",
2203                "    > a",
2204                "    > b",
2205                "    > C",
2206                "      .dockerignore",
2207                "v root2",
2208                "    > d",
2209                "    > e",
2210            ]
2211        );
2212
2213        // Add a file with the root folder selected. The filename editor is placed
2214        // before the first file in the root folder.
2215        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2216        panel.update(cx, |panel, cx| {
2217            assert!(panel.filename_editor.read(cx).is_focused(cx));
2218        });
2219        assert_eq!(
2220            visible_entries_as_strings(&panel, 0..10, cx),
2221            &[
2222                "v root1",
2223                "    > .git",
2224                "    > a",
2225                "    > b",
2226                "    > C",
2227                "      [EDITOR: '']  <== selected",
2228                "      .dockerignore",
2229                "v root2",
2230                "    > d",
2231                "    > e",
2232            ]
2233        );
2234
2235        let confirm = panel.update(cx, |panel, cx| {
2236            panel.filename_editor.update(cx, |editor, cx| {
2237                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2238            });
2239            panel.confirm_edit(cx).unwrap()
2240        });
2241
2242        assert_eq!(
2243            visible_entries_as_strings(&panel, 0..10, cx),
2244            &[
2245                "v root1",
2246                "    > .git",
2247                "    > a",
2248                "    > b",
2249                "    > C",
2250                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2251                "      .dockerignore",
2252                "v root2",
2253                "    > d",
2254                "    > e",
2255            ]
2256        );
2257
2258        confirm.await.unwrap();
2259        assert_eq!(
2260            visible_entries_as_strings(&panel, 0..13, cx),
2261            &[
2262                "v root1",
2263                "    > .git",
2264                "    > a",
2265                "    > b",
2266                "    v bdir1",
2267                "        v dir2",
2268                "              the-new-filename  <== selected",
2269                "    > C",
2270                "      .dockerignore",
2271                "v root2",
2272                "    > d",
2273                "    > e",
2274            ]
2275        );
2276    }
2277
2278    #[gpui::test]
2279    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2280        init_test(cx);
2281
2282        let fs = FakeFs::new(cx.executor().clone());
2283        fs.insert_tree(
2284            "/root1",
2285            json!({
2286                "one.two.txt": "",
2287                "one.txt": ""
2288            }),
2289        )
2290        .await;
2291
2292        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2293        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2294        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2295        let panel = workspace
2296            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2297            .unwrap();
2298
2299        panel.update(cx, |panel, cx| {
2300            panel.select_next(&Default::default(), cx);
2301            panel.select_next(&Default::default(), cx);
2302        });
2303
2304        assert_eq!(
2305            visible_entries_as_strings(&panel, 0..50, cx),
2306            &[
2307                //
2308                "v root1",
2309                "      one.two.txt  <== selected",
2310                "      one.txt",
2311            ]
2312        );
2313
2314        // Regression test - file name is created correctly when
2315        // the copied file's name contains multiple dots.
2316        panel.update(cx, |panel, cx| {
2317            panel.copy(&Default::default(), cx);
2318            panel.paste(&Default::default(), cx);
2319        });
2320        cx.executor().run_until_parked();
2321
2322        assert_eq!(
2323            visible_entries_as_strings(&panel, 0..50, cx),
2324            &[
2325                //
2326                "v root1",
2327                "      one.two copy.txt",
2328                "      one.two.txt  <== selected",
2329                "      one.txt",
2330            ]
2331        );
2332
2333        panel.update(cx, |panel, cx| {
2334            panel.paste(&Default::default(), cx);
2335        });
2336        cx.executor().run_until_parked();
2337
2338        assert_eq!(
2339            visible_entries_as_strings(&panel, 0..50, cx),
2340            &[
2341                //
2342                "v root1",
2343                "      one.two copy 1.txt",
2344                "      one.two copy.txt",
2345                "      one.two.txt  <== selected",
2346                "      one.txt",
2347            ]
2348        );
2349    }
2350
2351    #[gpui::test]
2352    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2353        init_test_with_editor(cx);
2354
2355        let fs = FakeFs::new(cx.executor().clone());
2356        fs.insert_tree(
2357            "/src",
2358            json!({
2359                "test": {
2360                    "first.rs": "// First Rust file",
2361                    "second.rs": "// Second Rust file",
2362                    "third.rs": "// Third Rust file",
2363                }
2364            }),
2365        )
2366        .await;
2367
2368        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2369        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2370        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2371        let panel = workspace
2372            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2373            .unwrap();
2374
2375        toggle_expand_dir(&panel, "src/test", cx);
2376        select_path(&panel, "src/test/first.rs", cx);
2377        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2378        cx.executor().run_until_parked();
2379        assert_eq!(
2380            visible_entries_as_strings(&panel, 0..10, cx),
2381            &[
2382                "v src",
2383                "    v test",
2384                "          first.rs  <== selected",
2385                "          second.rs",
2386                "          third.rs"
2387            ]
2388        );
2389        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2390
2391        submit_deletion(&panel, cx);
2392        assert_eq!(
2393            visible_entries_as_strings(&panel, 0..10, cx),
2394            &[
2395                "v src",
2396                "    v test",
2397                "          second.rs",
2398                "          third.rs"
2399            ],
2400            "Project panel should have no deleted file, no other file is selected in it"
2401        );
2402        ensure_no_open_items_and_panes(&workspace, cx);
2403
2404        select_path(&panel, "src/test/second.rs", cx);
2405        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2406        cx.executor().run_until_parked();
2407        assert_eq!(
2408            visible_entries_as_strings(&panel, 0..10, cx),
2409            &[
2410                "v src",
2411                "    v test",
2412                "          second.rs  <== selected",
2413                "          third.rs"
2414            ]
2415        );
2416        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2417
2418        workspace
2419            .update(cx, |workspace, cx| {
2420                let active_items = workspace
2421                    .panes()
2422                    .iter()
2423                    .filter_map(|pane| pane.read(cx).active_item())
2424                    .collect::<Vec<_>>();
2425                assert_eq!(active_items.len(), 1);
2426                let open_editor = active_items
2427                    .into_iter()
2428                    .next()
2429                    .unwrap()
2430                    .downcast::<Editor>()
2431                    .expect("Open item should be an editor");
2432                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2433            })
2434            .unwrap();
2435        submit_deletion(&panel, cx);
2436        assert_eq!(
2437            visible_entries_as_strings(&panel, 0..10, cx),
2438            &["v src", "    v test", "          third.rs"],
2439            "Project panel should have no deleted file, with one last file remaining"
2440        );
2441        ensure_no_open_items_and_panes(&workspace, cx);
2442    }
2443
2444    #[gpui::test]
2445    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2446        init_test_with_editor(cx);
2447
2448        let fs = FakeFs::new(cx.executor().clone());
2449        fs.insert_tree(
2450            "/src",
2451            json!({
2452                "test": {
2453                    "first.rs": "// First Rust file",
2454                    "second.rs": "// Second Rust file",
2455                    "third.rs": "// Third Rust file",
2456                }
2457            }),
2458        )
2459        .await;
2460
2461        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2462        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2463        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2464        let panel = workspace
2465            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2466            .unwrap();
2467
2468        select_path(&panel, "src/", cx);
2469        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2470        cx.executor().run_until_parked();
2471        assert_eq!(
2472            visible_entries_as_strings(&panel, 0..10, cx),
2473            &[
2474                //
2475                "v src  <== selected",
2476                "    > test"
2477            ]
2478        );
2479        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2480        panel.update(cx, |panel, cx| {
2481            assert!(panel.filename_editor.read(cx).is_focused(cx));
2482        });
2483        assert_eq!(
2484            visible_entries_as_strings(&panel, 0..10, cx),
2485            &[
2486                //
2487                "v src",
2488                "    > [EDITOR: '']  <== selected",
2489                "    > test"
2490            ]
2491        );
2492        panel.update(cx, |panel, cx| {
2493            panel
2494                .filename_editor
2495                .update(cx, |editor, cx| editor.set_text("test", cx));
2496            assert!(
2497                panel.confirm_edit(cx).is_none(),
2498                "Should not allow to confirm on conflicting new directory name"
2499            )
2500        });
2501        assert_eq!(
2502            visible_entries_as_strings(&panel, 0..10, cx),
2503            &[
2504                //
2505                "v src",
2506                "    > test"
2507            ],
2508            "File list should be unchanged after failed folder create confirmation"
2509        );
2510
2511        select_path(&panel, "src/test/", cx);
2512        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2513        cx.executor().run_until_parked();
2514        assert_eq!(
2515            visible_entries_as_strings(&panel, 0..10, cx),
2516            &[
2517                //
2518                "v src",
2519                "    > test  <== selected"
2520            ]
2521        );
2522        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2523        panel.update(cx, |panel, cx| {
2524            assert!(panel.filename_editor.read(cx).is_focused(cx));
2525        });
2526        assert_eq!(
2527            visible_entries_as_strings(&panel, 0..10, cx),
2528            &[
2529                "v src",
2530                "    v test",
2531                "          [EDITOR: '']  <== selected",
2532                "          first.rs",
2533                "          second.rs",
2534                "          third.rs"
2535            ]
2536        );
2537        panel.update(cx, |panel, cx| {
2538            panel
2539                .filename_editor
2540                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2541            assert!(
2542                panel.confirm_edit(cx).is_none(),
2543                "Should not allow to confirm on conflicting new file name"
2544            )
2545        });
2546        assert_eq!(
2547            visible_entries_as_strings(&panel, 0..10, cx),
2548            &[
2549                "v src",
2550                "    v test",
2551                "          first.rs",
2552                "          second.rs",
2553                "          third.rs"
2554            ],
2555            "File list should be unchanged after failed file create confirmation"
2556        );
2557
2558        select_path(&panel, "src/test/first.rs", cx);
2559        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2560        cx.executor().run_until_parked();
2561        assert_eq!(
2562            visible_entries_as_strings(&panel, 0..10, cx),
2563            &[
2564                "v src",
2565                "    v test",
2566                "          first.rs  <== selected",
2567                "          second.rs",
2568                "          third.rs"
2569            ],
2570        );
2571        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2572        panel.update(cx, |panel, cx| {
2573            assert!(panel.filename_editor.read(cx).is_focused(cx));
2574        });
2575        assert_eq!(
2576            visible_entries_as_strings(&panel, 0..10, cx),
2577            &[
2578                "v src",
2579                "    v test",
2580                "          [EDITOR: 'first.rs']  <== selected",
2581                "          second.rs",
2582                "          third.rs"
2583            ]
2584        );
2585        panel.update(cx, |panel, cx| {
2586            panel
2587                .filename_editor
2588                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2589            assert!(
2590                panel.confirm_edit(cx).is_none(),
2591                "Should not allow to confirm on conflicting file rename"
2592            )
2593        });
2594        assert_eq!(
2595            visible_entries_as_strings(&panel, 0..10, cx),
2596            &[
2597                "v src",
2598                "    v test",
2599                "          first.rs  <== selected",
2600                "          second.rs",
2601                "          third.rs"
2602            ],
2603            "File list should be unchanged after failed rename confirmation"
2604        );
2605    }
2606
2607    #[gpui::test]
2608    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2609        init_test_with_editor(cx);
2610
2611        let fs = FakeFs::new(cx.executor().clone());
2612        fs.insert_tree(
2613            "/src",
2614            json!({
2615                "test": {
2616                    "first.rs": "// First Rust file",
2617                    "second.rs": "// Second Rust file",
2618                    "third.rs": "// Third Rust file",
2619                }
2620            }),
2621        )
2622        .await;
2623
2624        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2625        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2626        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2627        let panel = workspace
2628            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2629            .unwrap();
2630
2631        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2632        let _subscription = panel.update(cx, |_, cx| {
2633            let subcription_count = Arc::clone(&new_search_events_count);
2634            let view = cx.view().clone();
2635            cx.subscribe(&view, move |_, _, event, _| {
2636                if matches!(event, Event::NewSearchInDirectory { .. }) {
2637                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2638                }
2639            })
2640        });
2641
2642        toggle_expand_dir(&panel, "src/test", cx);
2643        select_path(&panel, "src/test/first.rs", cx);
2644        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2645        cx.executor().run_until_parked();
2646        assert_eq!(
2647            visible_entries_as_strings(&panel, 0..10, cx),
2648            &[
2649                "v src",
2650                "    v test",
2651                "          first.rs  <== selected",
2652                "          second.rs",
2653                "          third.rs"
2654            ]
2655        );
2656        panel.update(cx, |panel, cx| {
2657            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2658        });
2659        assert_eq!(
2660            new_search_events_count.load(atomic::Ordering::SeqCst),
2661            0,
2662            "Should not trigger new search in directory when called on a file"
2663        );
2664
2665        select_path(&panel, "src/test", cx);
2666        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2667        cx.executor().run_until_parked();
2668        assert_eq!(
2669            visible_entries_as_strings(&panel, 0..10, cx),
2670            &[
2671                "v src",
2672                "    v test  <== selected",
2673                "          first.rs",
2674                "          second.rs",
2675                "          third.rs"
2676            ]
2677        );
2678        panel.update(cx, |panel, cx| {
2679            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2680        });
2681        assert_eq!(
2682            new_search_events_count.load(atomic::Ordering::SeqCst),
2683            1,
2684            "Should trigger new search in directory when called on a directory"
2685        );
2686    }
2687
2688    #[gpui::test]
2689    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2690        init_test_with_editor(cx);
2691
2692        let fs = FakeFs::new(cx.executor().clone());
2693        fs.insert_tree(
2694            "/project_root",
2695            json!({
2696                "dir_1": {
2697                    "nested_dir": {
2698                        "file_a.py": "# File contents",
2699                        "file_b.py": "# File contents",
2700                        "file_c.py": "# File contents",
2701                    },
2702                    "file_1.py": "# File contents",
2703                    "file_2.py": "# File contents",
2704                    "file_3.py": "# File contents",
2705                },
2706                "dir_2": {
2707                    "file_1.py": "# File contents",
2708                    "file_2.py": "# File contents",
2709                    "file_3.py": "# File contents",
2710                }
2711            }),
2712        )
2713        .await;
2714
2715        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2716        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2717        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2718        let panel = workspace
2719            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2720            .unwrap();
2721
2722        panel.update(cx, |panel, cx| {
2723            panel.collapse_all_entries(&CollapseAllEntries, cx)
2724        });
2725        cx.executor().run_until_parked();
2726        assert_eq!(
2727            visible_entries_as_strings(&panel, 0..10, cx),
2728            &["v project_root", "    > dir_1", "    > dir_2",]
2729        );
2730
2731        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2732        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2733        cx.executor().run_until_parked();
2734        assert_eq!(
2735            visible_entries_as_strings(&panel, 0..10, cx),
2736            &[
2737                "v project_root",
2738                "    v dir_1  <== selected",
2739                "        > nested_dir",
2740                "          file_1.py",
2741                "          file_2.py",
2742                "          file_3.py",
2743                "    > dir_2",
2744            ]
2745        );
2746    }
2747
2748    #[gpui::test]
2749    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2750        init_test(cx);
2751
2752        let fs = FakeFs::new(cx.executor().clone());
2753        fs.as_fake().insert_tree("/root", json!({})).await;
2754        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2755        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2756        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2757        let panel = workspace
2758            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2759            .unwrap();
2760
2761        // Make a new buffer with no backing file
2762        workspace
2763            .update(cx, |workspace, cx| {
2764                Editor::new_file(workspace, &Default::default(), cx)
2765            })
2766            .unwrap();
2767
2768        // "Save as"" the buffer, creating a new backing file for it
2769        let save_task = workspace
2770            .update(cx, |workspace, cx| {
2771                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2772            })
2773            .unwrap();
2774
2775        cx.executor().run_until_parked();
2776        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2777        save_task.await.unwrap();
2778
2779        // Rename the file
2780        select_path(&panel, "root/new", cx);
2781        assert_eq!(
2782            visible_entries_as_strings(&panel, 0..10, cx),
2783            &["v root", "      new  <== selected"]
2784        );
2785        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2786        panel.update(cx, |panel, cx| {
2787            panel
2788                .filename_editor
2789                .update(cx, |editor, cx| editor.set_text("newer", cx));
2790        });
2791        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2792
2793        cx.executor().run_until_parked();
2794        assert_eq!(
2795            visible_entries_as_strings(&panel, 0..10, cx),
2796            &["v root", "      newer  <== selected"]
2797        );
2798
2799        workspace
2800            .update(cx, |workspace, cx| {
2801                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2802            })
2803            .unwrap()
2804            .await
2805            .unwrap();
2806
2807        cx.executor().run_until_parked();
2808        // assert that saving the file doesn't restore "new"
2809        assert_eq!(
2810            visible_entries_as_strings(&panel, 0..10, cx),
2811            &["v root", "      newer  <== selected"]
2812        );
2813    }
2814
2815    fn toggle_expand_dir(
2816        panel: &View<ProjectPanel>,
2817        path: impl AsRef<Path>,
2818        cx: &mut VisualTestContext,
2819    ) {
2820        let path = path.as_ref();
2821        panel.update(cx, |panel, cx| {
2822            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2823                let worktree = worktree.read(cx);
2824                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2825                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2826                    panel.toggle_expanded(entry_id, cx);
2827                    return;
2828                }
2829            }
2830            panic!("no worktree for path {:?}", path);
2831        });
2832    }
2833
2834    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2835        let path = path.as_ref();
2836        panel.update(cx, |panel, cx| {
2837            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2838                let worktree = worktree.read(cx);
2839                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2840                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2841                    panel.selection = Some(Selection {
2842                        worktree_id: worktree.id(),
2843                        entry_id,
2844                    });
2845                    return;
2846                }
2847            }
2848            panic!("no worktree for path {:?}", path);
2849        });
2850    }
2851
2852    fn visible_entries_as_strings(
2853        panel: &View<ProjectPanel>,
2854        range: Range<usize>,
2855        cx: &mut VisualTestContext,
2856    ) -> Vec<String> {
2857        let mut result = Vec::new();
2858        let mut project_entries = HashSet::new();
2859        let mut has_editor = false;
2860
2861        panel.update(cx, |panel, cx| {
2862            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2863                if details.is_editing {
2864                    assert!(!has_editor, "duplicate editor entry");
2865                    has_editor = true;
2866                } else {
2867                    assert!(
2868                        project_entries.insert(project_entry),
2869                        "duplicate project entry {:?} {:?}",
2870                        project_entry,
2871                        details
2872                    );
2873                }
2874
2875                let indent = "    ".repeat(details.depth);
2876                let icon = if details.kind.is_dir() {
2877                    if details.is_expanded {
2878                        "v "
2879                    } else {
2880                        "> "
2881                    }
2882                } else {
2883                    "  "
2884                };
2885                let name = if details.is_editing {
2886                    format!("[EDITOR: '{}']", details.filename)
2887                } else if details.is_processing {
2888                    format!("[PROCESSING: '{}']", details.filename)
2889                } else {
2890                    details.filename.clone()
2891                };
2892                let selected = if details.is_selected {
2893                    "  <== selected"
2894                } else {
2895                    ""
2896                };
2897                result.push(format!("{indent}{icon}{name}{selected}"));
2898            });
2899        });
2900
2901        result
2902    }
2903
2904    fn init_test(cx: &mut TestAppContext) {
2905        cx.update(|cx| {
2906            let settings_store = SettingsStore::test(cx);
2907            cx.set_global(settings_store);
2908            init_settings(cx);
2909            theme::init(theme::LoadThemes::JustBase, cx);
2910            language::init(cx);
2911            editor::init_settings(cx);
2912            crate::init((), cx);
2913            workspace::init_settings(cx);
2914            client::init_settings(cx);
2915            Project::init_settings(cx);
2916
2917            cx.update_global::<SettingsStore, _>(|store, cx| {
2918                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2919                    project_settings.file_scan_exclusions = Some(Vec::new());
2920                });
2921            });
2922        });
2923    }
2924
2925    fn init_test_with_editor(cx: &mut TestAppContext) {
2926        cx.update(|cx| {
2927            let app_state = AppState::test(cx);
2928            theme::init(theme::LoadThemes::JustBase, cx);
2929            init_settings(cx);
2930            language::init(cx);
2931            editor::init(cx);
2932            crate::init((), cx);
2933            workspace::init(app_state.clone(), cx);
2934            Project::init_settings(cx);
2935        });
2936    }
2937
2938    fn ensure_single_file_is_opened(
2939        window: &WindowHandle<Workspace>,
2940        expected_path: &str,
2941        cx: &mut TestAppContext,
2942    ) {
2943        window
2944            .update(cx, |workspace, cx| {
2945                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2946                assert_eq!(worktrees.len(), 1);
2947                let worktree_id = worktrees[0].read(cx).id();
2948
2949                let open_project_paths = workspace
2950                    .panes()
2951                    .iter()
2952                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2953                    .collect::<Vec<_>>();
2954                assert_eq!(
2955                    open_project_paths,
2956                    vec![ProjectPath {
2957                        worktree_id,
2958                        path: Arc::from(Path::new(expected_path))
2959                    }],
2960                    "Should have opened file, selected in project panel"
2961                );
2962            })
2963            .unwrap();
2964    }
2965
2966    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2967        assert!(
2968            !cx.has_pending_prompt(),
2969            "Should have no prompts before the deletion"
2970        );
2971        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2972        assert!(
2973            cx.has_pending_prompt(),
2974            "Should have a prompt after the deletion"
2975        );
2976        cx.simulate_prompt_answer(0);
2977        assert!(
2978            !cx.has_pending_prompt(),
2979            "Should have no prompts after prompt was replied to"
2980        );
2981        cx.executor().run_until_parked();
2982    }
2983
2984    fn ensure_no_open_items_and_panes(
2985        workspace: &WindowHandle<Workspace>,
2986        cx: &mut VisualTestContext,
2987    ) {
2988        assert!(
2989            !cx.has_pending_prompt(),
2990            "Should have no prompts after deletion operation closes the file"
2991        );
2992        workspace
2993            .read_with(cx, |workspace, cx| {
2994                let open_project_paths = workspace
2995                    .panes()
2996                    .iter()
2997                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2998                    .collect::<Vec<_>>();
2999                assert!(
3000                    open_project_paths.is_empty(),
3001                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3002                );
3003            })
3004            .unwrap();
3005    }
3006}