project_panel.rs

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