project_panel.rs

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