project_panel.rs

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