project_panel.rs

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