project_panel.rs

   1use context_menu::{ContextMenu, ContextMenuItem};
   2use editor::{Cancel, Editor};
   3use futures::stream::StreamExt;
   4use gpui::{
   5    actions,
   6    anyhow::{anyhow, Result},
   7    elements::{
   8        ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement,
   9        ScrollTarget, Stack, Svg, UniformList, UniformListState,
  10    },
  11    geometry::vector::Vector2F,
  12    impl_internal_actions, keymap,
  13    platform::CursorStyle,
  14    AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
  15    PromptLevel, RenderContext, Task, View, ViewContext, ViewHandle,
  16};
  17use menu::{Confirm, SelectNext, SelectPrev};
  18use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
  19use settings::Settings;
  20use std::{
  21    cmp::Ordering,
  22    collections::{hash_map, HashMap},
  23    ffi::OsStr,
  24    ops::Range,
  25    path::{Path, PathBuf},
  26};
  27use unicase::UniCase;
  28use workspace::Workspace;
  29
  30const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  31
  32pub struct ProjectPanel {
  33    project: ModelHandle<Project>,
  34    list: UniformListState,
  35    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  36    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  37    selection: Option<Selection>,
  38    edit_state: Option<EditState>,
  39    filename_editor: ViewHandle<Editor>,
  40    clipboard_entry: Option<ClipboardEntry>,
  41    context_menu: ViewHandle<ContextMenu>,
  42}
  43
  44#[derive(Copy, Clone)]
  45struct Selection {
  46    worktree_id: WorktreeId,
  47    entry_id: ProjectEntryId,
  48}
  49
  50#[derive(Clone, Debug)]
  51struct EditState {
  52    worktree_id: WorktreeId,
  53    entry_id: ProjectEntryId,
  54    is_new_entry: bool,
  55    is_dir: bool,
  56    processing_filename: Option<String>,
  57}
  58
  59#[derive(Copy, Clone)]
  60pub enum ClipboardEntry {
  61    Copied {
  62        worktree_id: WorktreeId,
  63        entry_id: ProjectEntryId,
  64    },
  65    Cut {
  66        worktree_id: WorktreeId,
  67        entry_id: ProjectEntryId,
  68    },
  69}
  70
  71#[derive(Debug, PartialEq, Eq)]
  72struct EntryDetails {
  73    filename: String,
  74    depth: usize,
  75    kind: EntryKind,
  76    is_ignored: bool,
  77    is_expanded: bool,
  78    is_selected: bool,
  79    is_editing: bool,
  80    is_processing: bool,
  81    is_cut: bool,
  82}
  83
  84#[derive(Clone, PartialEq)]
  85pub struct ToggleExpanded(pub ProjectEntryId);
  86
  87#[derive(Clone, PartialEq)]
  88pub struct Open {
  89    pub entry_id: ProjectEntryId,
  90    pub change_focus: bool,
  91}
  92
  93#[derive(Clone, PartialEq)]
  94pub struct DeployContextMenu {
  95    pub position: Vector2F,
  96    pub entry_id: Option<ProjectEntryId>,
  97}
  98
  99actions!(
 100    project_panel,
 101    [
 102        ExpandSelectedEntry,
 103        CollapseSelectedEntry,
 104        AddDirectory,
 105        AddFile,
 106        Copy,
 107        CopyPath,
 108        Cut,
 109        Paste,
 110        Delete,
 111        Rename,
 112        Toggle
 113    ]
 114);
 115impl_internal_actions!(project_panel, [Open, ToggleExpanded, DeployContextMenu]);
 116
 117pub fn init(cx: &mut MutableAppContext) {
 118    cx.add_action(ProjectPanel::deploy_context_menu);
 119    cx.add_action(ProjectPanel::expand_selected_entry);
 120    cx.add_action(ProjectPanel::collapse_selected_entry);
 121    cx.add_action(ProjectPanel::toggle_expanded);
 122    cx.add_action(ProjectPanel::select_prev);
 123    cx.add_action(ProjectPanel::select_next);
 124    cx.add_action(ProjectPanel::open_entry);
 125    cx.add_action(ProjectPanel::add_file);
 126    cx.add_action(ProjectPanel::add_directory);
 127    cx.add_action(ProjectPanel::rename);
 128    cx.add_async_action(ProjectPanel::delete);
 129    cx.add_async_action(ProjectPanel::confirm);
 130    cx.add_action(ProjectPanel::cancel);
 131    cx.add_action(ProjectPanel::copy);
 132    cx.add_action(ProjectPanel::copy_path);
 133    cx.add_action(ProjectPanel::cut);
 134    cx.add_action(
 135        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
 136            this.paste(action, cx);
 137        },
 138    );
 139}
 140
 141pub enum Event {
 142    OpenedEntry {
 143        entry_id: ProjectEntryId,
 144        focus_opened_item: bool,
 145    },
 146}
 147
 148impl ProjectPanel {
 149    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 150        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 151            cx.observe(&project, |this, _, cx| {
 152                this.update_visible_entries(None, cx);
 153                cx.notify();
 154            })
 155            .detach();
 156            cx.subscribe(&project, |this, project, event, cx| match event {
 157                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 158                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 159                    {
 160                        this.expand_entry(worktree_id, *entry_id, cx);
 161                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 162                        this.autoscroll();
 163                        cx.notify();
 164                    }
 165                }
 166                project::Event::WorktreeRemoved(id) => {
 167                    this.expanded_dir_ids.remove(id);
 168                    this.update_visible_entries(None, cx);
 169                    cx.notify();
 170                }
 171                _ => {}
 172            })
 173            .detach();
 174
 175            let filename_editor = cx.add_view(|cx| {
 176                Editor::single_line(
 177                    Some(|theme| {
 178                        let mut style = theme.project_panel.filename_editor.clone();
 179                        style.container.background_color.take();
 180                        style
 181                    }),
 182                    cx,
 183                )
 184            });
 185
 186            let mut this = Self {
 187                project: project.clone(),
 188                list: Default::default(),
 189                visible_entries: Default::default(),
 190                expanded_dir_ids: Default::default(),
 191                selection: None,
 192                edit_state: None,
 193                filename_editor,
 194                clipboard_entry: None,
 195                context_menu: cx.add_view(|cx| ContextMenu::new(cx)),
 196            };
 197            this.update_visible_entries(None, cx);
 198            this
 199        });
 200        cx.subscribe(&project_panel, {
 201            let project_panel = project_panel.downgrade();
 202            move |workspace, _, event, cx| match event {
 203                &Event::OpenedEntry {
 204                    entry_id,
 205                    focus_opened_item,
 206                } => {
 207                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 208                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 209                            workspace
 210                                .open_path(
 211                                    ProjectPath {
 212                                        worktree_id: worktree.read(cx).id(),
 213                                        path: entry.path.clone(),
 214                                    },
 215                                    focus_opened_item,
 216                                    cx,
 217                                )
 218                                .detach_and_log_err(cx);
 219                            if !focus_opened_item {
 220                                if let Some(project_panel) = project_panel.upgrade(cx) {
 221                                    cx.focus(&project_panel);
 222                                }
 223                            }
 224                        }
 225                    }
 226                }
 227            }
 228        })
 229        .detach();
 230
 231        project_panel
 232    }
 233
 234    fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
 235        let mut menu_entries = Vec::new();
 236
 237        if let Some(entry_id) = action.entry_id {
 238            if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 239                self.selection = Some(Selection {
 240                    worktree_id,
 241                    entry_id,
 242                });
 243
 244                if let Some((worktree, entry)) = self.selected_entry(cx) {
 245                    let is_root = Some(entry) == worktree.root_entry();
 246                    if !self.project.read(cx).is_remote() {
 247                        menu_entries.push(ContextMenuItem::item(
 248                            "Add Folder to Project",
 249                            workspace::AddFolderToProject,
 250                        ));
 251                        if is_root {
 252                            menu_entries.push(ContextMenuItem::item(
 253                                "Remove from Project",
 254                                workspace::RemoveWorktreeFromProject(worktree_id),
 255                            ));
 256                        }
 257                    }
 258                    menu_entries.push(ContextMenuItem::item("New File", AddFile));
 259                    menu_entries.push(ContextMenuItem::item("New Folder", AddDirectory));
 260                    menu_entries.push(ContextMenuItem::Separator);
 261                    menu_entries.push(ContextMenuItem::item("Copy", Copy));
 262                    menu_entries.push(ContextMenuItem::item("Copy Path", CopyPath));
 263                    menu_entries.push(ContextMenuItem::item("Cut", Cut));
 264                    if let Some(clipboard_entry) = self.clipboard_entry {
 265                        if clipboard_entry.worktree_id() == worktree.id() {
 266                            menu_entries.push(ContextMenuItem::item("Paste", Paste));
 267                        }
 268                    }
 269                    menu_entries.push(ContextMenuItem::Separator);
 270                    menu_entries.push(ContextMenuItem::item("Rename", Rename));
 271                    if !is_root {
 272                        menu_entries.push(ContextMenuItem::item("Delete", Delete));
 273                    }
 274                }
 275            }
 276        } else {
 277            self.selection.take();
 278            menu_entries.push(ContextMenuItem::item(
 279                "Add Folder to Project",
 280                workspace::AddFolderToProject,
 281            ));
 282        }
 283
 284        self.context_menu.update(cx, |menu, cx| {
 285            menu.show(action.position, menu_entries, cx);
 286        });
 287
 288        cx.notify();
 289    }
 290
 291    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 292        if let Some((worktree, entry)) = self.selected_entry(cx) {
 293            if entry.is_dir() {
 294                let expanded_dir_ids =
 295                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 296                        expanded_dir_ids
 297                    } else {
 298                        return;
 299                    };
 300
 301                match expanded_dir_ids.binary_search(&entry.id) {
 302                    Ok(_) => self.select_next(&SelectNext, cx),
 303                    Err(ix) => {
 304                        expanded_dir_ids.insert(ix, entry.id);
 305                        self.update_visible_entries(None, cx);
 306                        cx.notify();
 307                    }
 308                }
 309            }
 310        }
 311    }
 312
 313    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 314        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 315            let expanded_dir_ids =
 316                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 317                    expanded_dir_ids
 318                } else {
 319                    return;
 320                };
 321
 322            loop {
 323                match expanded_dir_ids.binary_search(&entry.id) {
 324                    Ok(ix) => {
 325                        expanded_dir_ids.remove(ix);
 326                        self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
 327                        cx.notify();
 328                        break;
 329                    }
 330                    Err(_) => {
 331                        if let Some(parent_entry) =
 332                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 333                        {
 334                            entry = parent_entry;
 335                        } else {
 336                            break;
 337                        }
 338                    }
 339                }
 340            }
 341        }
 342    }
 343
 344    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
 345        let entry_id = action.0;
 346        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 347            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 348                match expanded_dir_ids.binary_search(&entry_id) {
 349                    Ok(ix) => {
 350                        expanded_dir_ids.remove(ix);
 351                    }
 352                    Err(ix) => {
 353                        expanded_dir_ids.insert(ix, entry_id);
 354                    }
 355                }
 356                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 357                cx.focus_self();
 358            }
 359        }
 360    }
 361
 362    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 363        if let Some(selection) = self.selection {
 364            let (mut worktree_ix, mut entry_ix, _) =
 365                self.index_for_selection(selection).unwrap_or_default();
 366            if entry_ix > 0 {
 367                entry_ix -= 1;
 368            } else {
 369                if worktree_ix > 0 {
 370                    worktree_ix -= 1;
 371                    entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 372                } else {
 373                    return;
 374                }
 375            }
 376
 377            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 378            self.selection = Some(Selection {
 379                worktree_id: *worktree_id,
 380                entry_id: worktree_entries[entry_ix].id,
 381            });
 382            self.autoscroll();
 383            cx.notify();
 384        } else {
 385            self.select_first(cx);
 386        }
 387    }
 388
 389    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 390        if let Some(task) = self.confirm_edit(cx) {
 391            Some(task)
 392        } else if let Some((_, entry)) = self.selected_entry(cx) {
 393            if entry.is_file() {
 394                self.open_entry(
 395                    &Open {
 396                        entry_id: entry.id,
 397                        change_focus: true,
 398                    },
 399                    cx,
 400                );
 401            }
 402            None
 403        } else {
 404            None
 405        }
 406    }
 407
 408    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 409        let edit_state = self.edit_state.as_mut()?;
 410        cx.focus_self();
 411
 412        let worktree_id = edit_state.worktree_id;
 413        let is_new_entry = edit_state.is_new_entry;
 414        let is_dir = edit_state.is_dir;
 415        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 416        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 417        let filename = self.filename_editor.read(cx).text(cx);
 418
 419        let edit_task;
 420        let edited_entry_id;
 421
 422        if is_new_entry {
 423            self.selection = Some(Selection {
 424                worktree_id,
 425                entry_id: NEW_ENTRY_ID,
 426            });
 427            let new_path = entry.path.join(&filename);
 428            edited_entry_id = NEW_ENTRY_ID;
 429            edit_task = self.project.update(cx, |project, cx| {
 430                project.create_entry((worktree_id, new_path), is_dir, cx)
 431            })?;
 432        } else {
 433            let new_path = if let Some(parent) = entry.path.clone().parent() {
 434                parent.join(&filename)
 435            } else {
 436                filename.clone().into()
 437            };
 438            edited_entry_id = entry.id;
 439            edit_task = self.project.update(cx, |project, cx| {
 440                project.rename_entry(entry.id, new_path, cx)
 441            })?;
 442        };
 443
 444        edit_state.processing_filename = Some(filename);
 445        cx.notify();
 446
 447        Some(cx.spawn(|this, mut cx| async move {
 448            let new_entry = edit_task.await;
 449            this.update(&mut cx, |this, cx| {
 450                this.edit_state.take();
 451                cx.notify();
 452            });
 453
 454            let new_entry = new_entry?;
 455            this.update(&mut cx, |this, cx| {
 456                if let Some(selection) = &mut this.selection {
 457                    if selection.entry_id == edited_entry_id {
 458                        selection.worktree_id = worktree_id;
 459                        selection.entry_id = new_entry.id;
 460                    }
 461                }
 462                this.update_visible_entries(None, cx);
 463                if is_new_entry && !is_dir {
 464                    this.open_entry(
 465                        &Open {
 466                            entry_id: new_entry.id,
 467                            change_focus: true,
 468                        },
 469                        cx,
 470                    );
 471                }
 472                cx.notify();
 473            });
 474            Ok(())
 475        }))
 476    }
 477
 478    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 479        self.edit_state = None;
 480        self.update_visible_entries(None, cx);
 481        cx.focus_self();
 482        cx.notify();
 483    }
 484
 485    fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
 486        cx.emit(Event::OpenedEntry {
 487            entry_id: action.entry_id,
 488            focus_opened_item: action.change_focus,
 489        });
 490    }
 491
 492    fn add_file(&mut self, _: &AddFile, cx: &mut ViewContext<Self>) {
 493        self.add_entry(false, cx)
 494    }
 495
 496    fn add_directory(&mut self, _: &AddDirectory, cx: &mut ViewContext<Self>) {
 497        self.add_entry(true, cx)
 498    }
 499
 500    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 501        if let Some(Selection {
 502            worktree_id,
 503            entry_id,
 504        }) = self.selection
 505        {
 506            let directory_id;
 507            if let Some((worktree, expanded_dir_ids)) = self
 508                .project
 509                .read(cx)
 510                .worktree_for_id(worktree_id, cx)
 511                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 512            {
 513                let worktree = worktree.read(cx);
 514                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 515                    loop {
 516                        if entry.is_dir() {
 517                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 518                                expanded_dir_ids.insert(ix, entry.id);
 519                            }
 520                            directory_id = entry.id;
 521                            break;
 522                        } else {
 523                            if let Some(parent_path) = entry.path.parent() {
 524                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 525                                    entry = parent_entry;
 526                                    continue;
 527                                }
 528                            }
 529                            return;
 530                        }
 531                    }
 532                } else {
 533                    return;
 534                };
 535            } else {
 536                return;
 537            };
 538
 539            self.edit_state = Some(EditState {
 540                worktree_id,
 541                entry_id: directory_id,
 542                is_new_entry: true,
 543                is_dir,
 544                processing_filename: None,
 545            });
 546            self.filename_editor
 547                .update(cx, |editor, cx| editor.clear(cx));
 548            cx.focus(&self.filename_editor);
 549            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 550            cx.notify();
 551        }
 552    }
 553
 554    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 555        if let Some(Selection {
 556            worktree_id,
 557            entry_id,
 558        }) = self.selection
 559        {
 560            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 561                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 562                    self.edit_state = Some(EditState {
 563                        worktree_id,
 564                        entry_id,
 565                        is_new_entry: false,
 566                        is_dir: entry.is_dir(),
 567                        processing_filename: None,
 568                    });
 569                    let filename = entry
 570                        .path
 571                        .file_name()
 572                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
 573                    self.filename_editor.update(cx, |editor, cx| {
 574                        editor.set_text(filename, cx);
 575                        editor.select_all(&Default::default(), cx);
 576                    });
 577                    cx.focus(&self.filename_editor);
 578                    self.update_visible_entries(None, cx);
 579                    cx.notify();
 580                }
 581            }
 582        }
 583    }
 584
 585    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 586        let Selection { entry_id, .. } = self.selection?;
 587        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 588        let file_name = path.file_name()?;
 589
 590        let mut answer = cx.prompt(
 591            PromptLevel::Info,
 592            &format!("Delete {file_name:?}?"),
 593            &["Delete", "Cancel"],
 594        );
 595        Some(cx.spawn(|this, mut cx| async move {
 596            if answer.next().await != Some(0) {
 597                return Ok(());
 598            }
 599            this.update(&mut cx, |this, cx| {
 600                this.project
 601                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 602                    .ok_or_else(|| anyhow!("no such entry"))
 603            })?
 604            .await
 605        }))
 606    }
 607
 608    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 609        if let Some(selection) = self.selection {
 610            let (mut worktree_ix, mut entry_ix, _) =
 611                self.index_for_selection(selection).unwrap_or_default();
 612            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 613                if entry_ix + 1 < worktree_entries.len() {
 614                    entry_ix += 1;
 615                } else {
 616                    worktree_ix += 1;
 617                    entry_ix = 0;
 618                }
 619            }
 620
 621            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 622                if let Some(entry) = worktree_entries.get(entry_ix) {
 623                    self.selection = Some(Selection {
 624                        worktree_id: *worktree_id,
 625                        entry_id: entry.id,
 626                    });
 627                    self.autoscroll();
 628                    cx.notify();
 629                }
 630            }
 631        } else {
 632            self.select_first(cx);
 633        }
 634    }
 635
 636    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 637        let worktree = self
 638            .visible_entries
 639            .first()
 640            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 641        if let Some(worktree) = worktree {
 642            let worktree = worktree.read(cx);
 643            let worktree_id = worktree.id();
 644            if let Some(root_entry) = worktree.root_entry() {
 645                self.selection = Some(Selection {
 646                    worktree_id,
 647                    entry_id: root_entry.id,
 648                });
 649                self.autoscroll();
 650                cx.notify();
 651            }
 652        }
 653    }
 654
 655    fn autoscroll(&mut self) {
 656        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 657            self.list.scroll_to(ScrollTarget::Show(index));
 658        }
 659    }
 660
 661    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 662        if let Some((worktree, entry)) = self.selected_entry(cx) {
 663            self.clipboard_entry = Some(ClipboardEntry::Cut {
 664                worktree_id: worktree.id(),
 665                entry_id: entry.id,
 666            });
 667            cx.notify();
 668        }
 669    }
 670
 671    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 672        if let Some((worktree, entry)) = self.selected_entry(cx) {
 673            self.clipboard_entry = Some(ClipboardEntry::Copied {
 674                worktree_id: worktree.id(),
 675                entry_id: entry.id,
 676            });
 677            cx.notify();
 678        }
 679    }
 680
 681    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
 682        if let Some((worktree, entry)) = self.selected_entry(cx) {
 683            let clipboard_entry = self.clipboard_entry?;
 684            if clipboard_entry.worktree_id() != worktree.id() {
 685                return None;
 686            }
 687
 688            let clipboard_entry_file_name = self
 689                .project
 690                .read(cx)
 691                .path_for_entry(clipboard_entry.entry_id(), cx)?
 692                .path
 693                .file_name()?
 694                .to_os_string();
 695
 696            let mut new_path = entry.path.to_path_buf();
 697            if entry.is_file() {
 698                new_path.pop();
 699            }
 700
 701            new_path.push(&clipboard_entry_file_name);
 702            let extension = new_path.extension().map(|e| e.to_os_string());
 703            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 704            let mut ix = 0;
 705            while worktree.entry_for_path(&new_path).is_some() {
 706                new_path.pop();
 707
 708                let mut new_file_name = file_name_without_extension.to_os_string();
 709                new_file_name.push(" copy");
 710                if ix > 0 {
 711                    new_file_name.push(format!(" {}", ix));
 712                }
 713                new_path.push(new_file_name);
 714                if let Some(extension) = extension.as_ref() {
 715                    new_path.set_extension(&extension);
 716                }
 717                ix += 1;
 718            }
 719
 720            self.clipboard_entry.take();
 721            if clipboard_entry.is_cut() {
 722                self.project
 723                    .update(cx, |project, cx| {
 724                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 725                    })
 726                    .map(|task| task.detach_and_log_err(cx));
 727            } else {
 728                self.project
 729                    .update(cx, |project, cx| {
 730                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 731                    })
 732                    .map(|task| task.detach_and_log_err(cx));
 733            }
 734        }
 735        None
 736    }
 737
 738    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 739        if let Some((worktree, entry)) = self.selected_entry(cx) {
 740            let mut path = PathBuf::new();
 741            path.push(worktree.root_name());
 742            path.push(&entry.path);
 743            cx.write_to_clipboard(ClipboardItem::new(path.to_string_lossy().to_string()));
 744        }
 745    }
 746
 747    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
 748        let mut worktree_index = 0;
 749        let mut entry_index = 0;
 750        let mut visible_entries_index = 0;
 751        for (worktree_id, worktree_entries) in &self.visible_entries {
 752            if *worktree_id == selection.worktree_id {
 753                for entry in worktree_entries {
 754                    if entry.id == selection.entry_id {
 755                        return Some((worktree_index, entry_index, visible_entries_index));
 756                    } else {
 757                        visible_entries_index += 1;
 758                        entry_index += 1;
 759                    }
 760                }
 761                break;
 762            } else {
 763                visible_entries_index += worktree_entries.len();
 764            }
 765            worktree_index += 1;
 766        }
 767        None
 768    }
 769
 770    fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
 771        let selection = self.selection?;
 772        let project = self.project.read(cx);
 773        let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
 774        Some((worktree, worktree.entry_for_id(selection.entry_id)?))
 775    }
 776
 777    fn update_visible_entries(
 778        &mut self,
 779        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 780        cx: &mut ViewContext<Self>,
 781    ) {
 782        let worktrees = self
 783            .project
 784            .read(cx)
 785            .worktrees(cx)
 786            .filter(|worktree| worktree.read(cx).is_visible());
 787        self.visible_entries.clear();
 788
 789        for worktree in worktrees {
 790            let snapshot = worktree.read(cx).snapshot();
 791            let worktree_id = snapshot.id();
 792
 793            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 794                hash_map::Entry::Occupied(e) => e.into_mut(),
 795                hash_map::Entry::Vacant(e) => {
 796                    // The first time a worktree's root entry becomes available,
 797                    // mark that root entry as expanded.
 798                    if let Some(entry) = snapshot.root_entry() {
 799                        e.insert(vec![entry.id]).as_slice()
 800                    } else {
 801                        &[]
 802                    }
 803                }
 804            };
 805
 806            let mut new_entry_parent_id = None;
 807            let mut new_entry_kind = EntryKind::Dir;
 808            if let Some(edit_state) = &self.edit_state {
 809                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
 810                    new_entry_parent_id = Some(edit_state.entry_id);
 811                    new_entry_kind = if edit_state.is_dir {
 812                        EntryKind::Dir
 813                    } else {
 814                        EntryKind::File(Default::default())
 815                    };
 816                }
 817            }
 818
 819            let mut visible_worktree_entries = Vec::new();
 820            let mut entry_iter = snapshot.entries(true);
 821            while let Some(entry) = entry_iter.entry() {
 822                visible_worktree_entries.push(entry.clone());
 823                if Some(entry.id) == new_entry_parent_id {
 824                    visible_worktree_entries.push(Entry {
 825                        id: NEW_ENTRY_ID,
 826                        kind: new_entry_kind,
 827                        path: entry.path.join("\0").into(),
 828                        inode: 0,
 829                        mtime: entry.mtime,
 830                        is_symlink: false,
 831                        is_ignored: false,
 832                    });
 833                }
 834                if expanded_dir_ids.binary_search(&entry.id).is_err() {
 835                    if entry_iter.advance_to_sibling() {
 836                        continue;
 837                    }
 838                }
 839                entry_iter.advance();
 840            }
 841            visible_worktree_entries.sort_by(|entry_a, entry_b| {
 842                let mut components_a = entry_a.path.components().peekable();
 843                let mut components_b = entry_b.path.components().peekable();
 844                loop {
 845                    match (components_a.next(), components_b.next()) {
 846                        (Some(component_a), Some(component_b)) => {
 847                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
 848                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
 849                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
 850                                let name_a =
 851                                    UniCase::new(component_a.as_os_str().to_string_lossy());
 852                                let name_b =
 853                                    UniCase::new(component_b.as_os_str().to_string_lossy());
 854                                name_a.cmp(&name_b)
 855                            });
 856                            if !ordering.is_eq() {
 857                                return ordering;
 858                            }
 859                        }
 860                        (Some(_), None) => break Ordering::Greater,
 861                        (None, Some(_)) => break Ordering::Less,
 862                        (None, None) => break Ordering::Equal,
 863                    }
 864                }
 865            });
 866            self.visible_entries
 867                .push((worktree_id, visible_worktree_entries));
 868        }
 869
 870        if let Some((worktree_id, entry_id)) = new_selected_entry {
 871            self.selection = Some(Selection {
 872                worktree_id,
 873                entry_id,
 874            });
 875        }
 876    }
 877
 878    fn expand_entry(
 879        &mut self,
 880        worktree_id: WorktreeId,
 881        entry_id: ProjectEntryId,
 882        cx: &mut ViewContext<Self>,
 883    ) {
 884        let project = self.project.read(cx);
 885        if let Some((worktree, expanded_dir_ids)) = project
 886            .worktree_for_id(worktree_id, cx)
 887            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 888        {
 889            let worktree = worktree.read(cx);
 890
 891            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 892                loop {
 893                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 894                        expanded_dir_ids.insert(ix, entry.id);
 895                    }
 896
 897                    if let Some(parent_entry) =
 898                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 899                    {
 900                        entry = parent_entry;
 901                    } else {
 902                        break;
 903                    }
 904                }
 905            }
 906        }
 907    }
 908
 909    fn for_each_visible_entry(
 910        &self,
 911        range: Range<usize>,
 912        cx: &mut RenderContext<ProjectPanel>,
 913        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut RenderContext<ProjectPanel>),
 914    ) {
 915        let mut ix = 0;
 916        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
 917            if ix >= range.end {
 918                return;
 919            }
 920
 921            if ix + visible_worktree_entries.len() <= range.start {
 922                ix += visible_worktree_entries.len();
 923                continue;
 924            }
 925
 926            let end_ix = range.end.min(ix + visible_worktree_entries.len());
 927            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
 928                let snapshot = worktree.read(cx).snapshot();
 929                let expanded_entry_ids = self
 930                    .expanded_dir_ids
 931                    .get(&snapshot.id())
 932                    .map(Vec::as_slice)
 933                    .unwrap_or(&[]);
 934                let root_name = OsStr::new(snapshot.root_name());
 935                for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
 936                {
 937                    let mut details = EntryDetails {
 938                        filename: entry
 939                            .path
 940                            .file_name()
 941                            .unwrap_or(root_name)
 942                            .to_string_lossy()
 943                            .to_string(),
 944                        depth: entry.path.components().count(),
 945                        kind: entry.kind,
 946                        is_ignored: entry.is_ignored,
 947                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
 948                        is_selected: self.selection.map_or(false, |e| {
 949                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
 950                        }),
 951                        is_editing: false,
 952                        is_processing: false,
 953                        is_cut: self
 954                            .clipboard_entry
 955                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
 956                    };
 957                    if let Some(edit_state) = &self.edit_state {
 958                        let is_edited_entry = if edit_state.is_new_entry {
 959                            entry.id == NEW_ENTRY_ID
 960                        } else {
 961                            entry.id == edit_state.entry_id
 962                        };
 963                        if is_edited_entry {
 964                            if let Some(processing_filename) = &edit_state.processing_filename {
 965                                details.is_processing = true;
 966                                details.filename.clear();
 967                                details.filename.push_str(&processing_filename);
 968                            } else {
 969                                if edit_state.is_new_entry {
 970                                    details.filename.clear();
 971                                }
 972                                details.is_editing = true;
 973                            }
 974                        }
 975                    }
 976
 977                    callback(entry.id, details, cx);
 978                }
 979            }
 980            ix = end_ix;
 981        }
 982    }
 983
 984    fn render_entry(
 985        entry_id: ProjectEntryId,
 986        details: EntryDetails,
 987        editor: &ViewHandle<Editor>,
 988        theme: &theme::ProjectPanel,
 989        cx: &mut RenderContext<Self>,
 990    ) -> ElementBox {
 991        let kind = details.kind;
 992        let show_editor = details.is_editing && !details.is_processing;
 993        MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
 994            let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
 995            let mut style = theme.entry.style_for(state, details.is_selected).clone();
 996            if details.is_ignored {
 997                style.text.color.fade_out(theme.ignored_entry_fade);
 998                style.icon_color.fade_out(theme.ignored_entry_fade);
 999            }
1000            if details.is_cut {
1001                style.text.color.fade_out(theme.cut_entry_fade);
1002                style.icon_color.fade_out(theme.cut_entry_fade);
1003            }
1004            let row_container_style = if show_editor {
1005                theme.filename_editor.container
1006            } else {
1007                style.container
1008            };
1009            Flex::row()
1010                .with_child(
1011                    ConstrainedBox::new(if kind == EntryKind::Dir {
1012                        if details.is_expanded {
1013                            Svg::new("icons/disclosure-open.svg")
1014                                .with_color(style.icon_color)
1015                                .boxed()
1016                        } else {
1017                            Svg::new("icons/disclosure-closed.svg")
1018                                .with_color(style.icon_color)
1019                                .boxed()
1020                        }
1021                    } else {
1022                        Empty::new().boxed()
1023                    })
1024                    .with_max_width(style.icon_size)
1025                    .with_max_height(style.icon_size)
1026                    .aligned()
1027                    .constrained()
1028                    .with_width(style.icon_size)
1029                    .boxed(),
1030                )
1031                .with_child(if show_editor {
1032                    ChildView::new(editor.clone())
1033                        .contained()
1034                        .with_margin_left(theme.entry.default.icon_spacing)
1035                        .aligned()
1036                        .left()
1037                        .flex(1.0, true)
1038                        .boxed()
1039                } else {
1040                    Label::new(details.filename, style.text.clone())
1041                        .contained()
1042                        .with_margin_left(style.icon_spacing)
1043                        .aligned()
1044                        .left()
1045                        .boxed()
1046                })
1047                .constrained()
1048                .with_height(theme.entry.default.height)
1049                .contained()
1050                .with_style(row_container_style)
1051                .with_padding_left(padding)
1052                .boxed()
1053        })
1054        .on_click(move |_, click_count, cx| {
1055            if kind == EntryKind::Dir {
1056                cx.dispatch_action(ToggleExpanded(entry_id))
1057            } else {
1058                cx.dispatch_action(Open {
1059                    entry_id,
1060                    change_focus: click_count > 1,
1061                })
1062            }
1063        })
1064        .on_right_mouse_down(move |position, cx| {
1065            cx.dispatch_action(DeployContextMenu {
1066                entry_id: Some(entry_id),
1067                position,
1068            })
1069        })
1070        .with_cursor_style(CursorStyle::PointingHand)
1071        .boxed()
1072    }
1073}
1074
1075impl View for ProjectPanel {
1076    fn ui_name() -> &'static str {
1077        "ProjectPanel"
1078    }
1079
1080    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
1081        enum Tag {}
1082        let theme = &cx.global::<Settings>().theme.project_panel;
1083        let mut container_style = theme.container;
1084        let padding = std::mem::take(&mut container_style.padding);
1085        Stack::new()
1086            .with_child(
1087                MouseEventHandler::new::<Tag, _, _>(0, cx, |_, cx| {
1088                    UniformList::new(
1089                        self.list.clone(),
1090                        self.visible_entries
1091                            .iter()
1092                            .map(|(_, worktree_entries)| worktree_entries.len())
1093                            .sum(),
1094                        cx,
1095                        move |this, range, items, cx| {
1096                            let theme = cx.global::<Settings>().theme.clone();
1097                            this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
1098                                items.push(Self::render_entry(
1099                                    id,
1100                                    details,
1101                                    &this.filename_editor,
1102                                    &theme.project_panel,
1103                                    cx,
1104                                ));
1105                            });
1106                        },
1107                    )
1108                    .with_padding_top(padding.top)
1109                    .with_padding_bottom(padding.bottom)
1110                    .contained()
1111                    .with_style(container_style)
1112                    .expanded()
1113                    .boxed()
1114                })
1115                .on_right_mouse_down(move |position, cx| {
1116                    cx.dispatch_action(DeployContextMenu {
1117                        entry_id: None,
1118                        position,
1119                    })
1120                })
1121                .boxed(),
1122            )
1123            .with_child(ChildView::new(&self.context_menu).boxed())
1124            .boxed()
1125    }
1126
1127    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1128        let mut cx = Self::default_keymap_context();
1129        cx.set.insert("menu".into());
1130        cx
1131    }
1132}
1133
1134impl Entity for ProjectPanel {
1135    type Event = Event;
1136}
1137
1138impl workspace::sidebar::SidebarItem for ProjectPanel {
1139    fn should_show_badge(&self, _: &AppContext) -> bool {
1140        false
1141    }
1142}
1143
1144impl ClipboardEntry {
1145    fn is_cut(&self) -> bool {
1146        matches!(self, Self::Cut { .. })
1147    }
1148
1149    fn entry_id(&self) -> ProjectEntryId {
1150        match self {
1151            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1152                *entry_id
1153            }
1154        }
1155    }
1156
1157    fn worktree_id(&self) -> WorktreeId {
1158        match self {
1159            ClipboardEntry::Copied { worktree_id, .. }
1160            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1161        }
1162    }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use gpui::{TestAppContext, ViewHandle};
1169    use project::FakeFs;
1170    use serde_json::json;
1171    use std::{collections::HashSet, path::Path};
1172
1173    #[gpui::test]
1174    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1175        cx.foreground().forbid_parking();
1176        cx.update(|cx| {
1177            let settings = Settings::test(cx);
1178            cx.set_global(settings);
1179        });
1180
1181        let fs = FakeFs::new(cx.background());
1182        fs.insert_tree(
1183            "/root1",
1184            json!({
1185                ".dockerignore": "",
1186                ".git": {
1187                    "HEAD": "",
1188                },
1189                "a": {
1190                    "0": { "q": "", "r": "", "s": "" },
1191                    "1": { "t": "", "u": "" },
1192                    "2": { "v": "", "w": "", "x": "", "y": "" },
1193                },
1194                "b": {
1195                    "3": { "Q": "" },
1196                    "4": { "R": "", "S": "", "T": "", "U": "" },
1197                },
1198                "C": {
1199                    "5": {},
1200                    "6": { "V": "", "W": "" },
1201                    "7": { "X": "" },
1202                    "8": { "Y": {}, "Z": "" }
1203                }
1204            }),
1205        )
1206        .await;
1207        fs.insert_tree(
1208            "/root2",
1209            json!({
1210                "d": {
1211                    "9": ""
1212                },
1213                "e": {}
1214            }),
1215        )
1216        .await;
1217
1218        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1219        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
1220        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1221        assert_eq!(
1222            visible_entries_as_strings(&panel, 0..50, cx),
1223            &[
1224                "v root1",
1225                "    > .git",
1226                "    > a",
1227                "    > b",
1228                "    > C",
1229                "      .dockerignore",
1230                "v root2",
1231                "    > d",
1232                "    > e",
1233            ]
1234        );
1235
1236        toggle_expand_dir(&panel, "root1/b", cx);
1237        assert_eq!(
1238            visible_entries_as_strings(&panel, 0..50, cx),
1239            &[
1240                "v root1",
1241                "    > .git",
1242                "    > a",
1243                "    v b  <== selected",
1244                "        > 3",
1245                "        > 4",
1246                "    > C",
1247                "      .dockerignore",
1248                "v root2",
1249                "    > d",
1250                "    > e",
1251            ]
1252        );
1253
1254        assert_eq!(
1255            visible_entries_as_strings(&panel, 6..9, cx),
1256            &[
1257                //
1258                "    > C",
1259                "      .dockerignore",
1260                "v root2",
1261            ]
1262        );
1263    }
1264
1265    #[gpui::test(iterations = 30)]
1266    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1267        cx.foreground().forbid_parking();
1268        cx.update(|cx| {
1269            let settings = Settings::test(cx);
1270            cx.set_global(settings);
1271        });
1272
1273        let fs = FakeFs::new(cx.background());
1274        fs.insert_tree(
1275            "/root1",
1276            json!({
1277                ".dockerignore": "",
1278                ".git": {
1279                    "HEAD": "",
1280                },
1281                "a": {
1282                    "0": { "q": "", "r": "", "s": "" },
1283                    "1": { "t": "", "u": "" },
1284                    "2": { "v": "", "w": "", "x": "", "y": "" },
1285                },
1286                "b": {
1287                    "3": { "Q": "" },
1288                    "4": { "R": "", "S": "", "T": "", "U": "" },
1289                },
1290                "C": {
1291                    "5": {},
1292                    "6": { "V": "", "W": "" },
1293                    "7": { "X": "" },
1294                    "8": { "Y": {}, "Z": "" }
1295                }
1296            }),
1297        )
1298        .await;
1299        fs.insert_tree(
1300            "/root2",
1301            json!({
1302                "d": {
1303                    "9": ""
1304                },
1305                "e": {}
1306            }),
1307        )
1308        .await;
1309
1310        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1311        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
1312        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1313
1314        select_path(&panel, "root1", cx);
1315        assert_eq!(
1316            visible_entries_as_strings(&panel, 0..10, cx),
1317            &[
1318                "v root1  <== selected",
1319                "    > .git",
1320                "    > a",
1321                "    > b",
1322                "    > C",
1323                "      .dockerignore",
1324                "v root2",
1325                "    > d",
1326                "    > e",
1327            ]
1328        );
1329
1330        // Add a file with the root folder selected. The filename editor is placed
1331        // before the first file in the root folder.
1332        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1333        assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1334        assert_eq!(
1335            visible_entries_as_strings(&panel, 0..10, cx),
1336            &[
1337                "v root1",
1338                "    > .git",
1339                "    > a",
1340                "    > b",
1341                "    > C",
1342                "      [EDITOR: '']  <== selected",
1343                "      .dockerignore",
1344                "v root2",
1345                "    > d",
1346                "    > e",
1347            ]
1348        );
1349
1350        let confirm = panel.update(cx, |panel, cx| {
1351            panel
1352                .filename_editor
1353                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1354            panel.confirm(&Confirm, cx).unwrap()
1355        });
1356        assert_eq!(
1357            visible_entries_as_strings(&panel, 0..10, cx),
1358            &[
1359                "v root1",
1360                "    > .git",
1361                "    > a",
1362                "    > b",
1363                "    > C",
1364                "      [PROCESSING: 'the-new-filename']  <== selected",
1365                "      .dockerignore",
1366                "v root2",
1367                "    > d",
1368                "    > e",
1369            ]
1370        );
1371
1372        confirm.await.unwrap();
1373        assert_eq!(
1374            visible_entries_as_strings(&panel, 0..10, cx),
1375            &[
1376                "v root1",
1377                "    > .git",
1378                "    > a",
1379                "    > b",
1380                "    > C",
1381                "      .dockerignore",
1382                "      the-new-filename  <== selected",
1383                "v root2",
1384                "    > d",
1385                "    > e",
1386            ]
1387        );
1388
1389        select_path(&panel, "root1/b", cx);
1390        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1391        assert_eq!(
1392            visible_entries_as_strings(&panel, 0..10, cx),
1393            &[
1394                "v root1",
1395                "    > .git",
1396                "    > a",
1397                "    v b",
1398                "        > 3",
1399                "        > 4",
1400                "          [EDITOR: '']  <== selected",
1401                "    > C",
1402                "      .dockerignore",
1403                "      the-new-filename",
1404            ]
1405        );
1406
1407        panel
1408            .update(cx, |panel, cx| {
1409                panel
1410                    .filename_editor
1411                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1412                panel.confirm(&Confirm, cx).unwrap()
1413            })
1414            .await
1415            .unwrap();
1416        assert_eq!(
1417            visible_entries_as_strings(&panel, 0..10, cx),
1418            &[
1419                "v root1",
1420                "    > .git",
1421                "    > a",
1422                "    v b",
1423                "        > 3",
1424                "        > 4",
1425                "          another-filename  <== selected",
1426                "    > C",
1427                "      .dockerignore",
1428                "      the-new-filename",
1429            ]
1430        );
1431
1432        select_path(&panel, "root1/b/another-filename", cx);
1433        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1434        assert_eq!(
1435            visible_entries_as_strings(&panel, 0..10, cx),
1436            &[
1437                "v root1",
1438                "    > .git",
1439                "    > a",
1440                "    v b",
1441                "        > 3",
1442                "        > 4",
1443                "          [EDITOR: 'another-filename']  <== selected",
1444                "    > C",
1445                "      .dockerignore",
1446                "      the-new-filename",
1447            ]
1448        );
1449
1450        let confirm = panel.update(cx, |panel, cx| {
1451            panel
1452                .filename_editor
1453                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1454            panel.confirm(&Confirm, cx).unwrap()
1455        });
1456        assert_eq!(
1457            visible_entries_as_strings(&panel, 0..10, cx),
1458            &[
1459                "v root1",
1460                "    > .git",
1461                "    > a",
1462                "    v b",
1463                "        > 3",
1464                "        > 4",
1465                "          [PROCESSING: 'a-different-filename']  <== selected",
1466                "    > C",
1467                "      .dockerignore",
1468                "      the-new-filename",
1469            ]
1470        );
1471
1472        confirm.await.unwrap();
1473        assert_eq!(
1474            visible_entries_as_strings(&panel, 0..10, cx),
1475            &[
1476                "v root1",
1477                "    > .git",
1478                "    > a",
1479                "    v b",
1480                "        > 3",
1481                "        > 4",
1482                "          a-different-filename  <== selected",
1483                "    > C",
1484                "      .dockerignore",
1485                "      the-new-filename",
1486            ]
1487        );
1488
1489        panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
1490        assert_eq!(
1491            visible_entries_as_strings(&panel, 0..10, cx),
1492            &[
1493                "v root1",
1494                "    > .git",
1495                "    > a",
1496                "    v b",
1497                "        > [EDITOR: '']  <== selected",
1498                "        > 3",
1499                "        > 4",
1500                "          a-different-filename",
1501                "    > C",
1502                "      .dockerignore",
1503            ]
1504        );
1505
1506        let confirm = panel.update(cx, |panel, cx| {
1507            panel
1508                .filename_editor
1509                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1510            panel.confirm(&Confirm, cx).unwrap()
1511        });
1512        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1513        assert_eq!(
1514            visible_entries_as_strings(&panel, 0..10, cx),
1515            &[
1516                "v root1",
1517                "    > .git",
1518                "    > a",
1519                "    v b",
1520                "        > [PROCESSING: 'new-dir']",
1521                "        > 3  <== selected",
1522                "        > 4",
1523                "          a-different-filename",
1524                "    > C",
1525                "      .dockerignore",
1526            ]
1527        );
1528
1529        confirm.await.unwrap();
1530        assert_eq!(
1531            visible_entries_as_strings(&panel, 0..10, cx),
1532            &[
1533                "v root1",
1534                "    > .git",
1535                "    > a",
1536                "    v b",
1537                "        > 3  <== selected",
1538                "        > 4",
1539                "        > new-dir",
1540                "          a-different-filename",
1541                "    > C",
1542                "      .dockerignore",
1543            ]
1544        );
1545    }
1546
1547    fn toggle_expand_dir(
1548        panel: &ViewHandle<ProjectPanel>,
1549        path: impl AsRef<Path>,
1550        cx: &mut TestAppContext,
1551    ) {
1552        let path = path.as_ref();
1553        panel.update(cx, |panel, cx| {
1554            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1555                let worktree = worktree.read(cx);
1556                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1557                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1558                    panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1559                    return;
1560                }
1561            }
1562            panic!("no worktree for path {:?}", path);
1563        });
1564    }
1565
1566    fn select_path(
1567        panel: &ViewHandle<ProjectPanel>,
1568        path: impl AsRef<Path>,
1569        cx: &mut TestAppContext,
1570    ) {
1571        let path = path.as_ref();
1572        panel.update(cx, |panel, cx| {
1573            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1574                let worktree = worktree.read(cx);
1575                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1576                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1577                    panel.selection = Some(Selection {
1578                        worktree_id: worktree.id(),
1579                        entry_id,
1580                    });
1581                    return;
1582                }
1583            }
1584            panic!("no worktree for path {:?}", path);
1585        });
1586    }
1587
1588    fn visible_entries_as_strings(
1589        panel: &ViewHandle<ProjectPanel>,
1590        range: Range<usize>,
1591        cx: &mut TestAppContext,
1592    ) -> Vec<String> {
1593        let mut result = Vec::new();
1594        let mut project_entries = HashSet::new();
1595        let mut has_editor = false;
1596        cx.render(panel, |panel, cx| {
1597            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1598                if details.is_editing {
1599                    assert!(!has_editor, "duplicate editor entry");
1600                    has_editor = true;
1601                } else {
1602                    assert!(
1603                        project_entries.insert(project_entry),
1604                        "duplicate project entry {:?} {:?}",
1605                        project_entry,
1606                        details
1607                    );
1608                }
1609
1610                let indent = "    ".repeat(details.depth);
1611                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1612                    if details.is_expanded {
1613                        "v "
1614                    } else {
1615                        "> "
1616                    }
1617                } else {
1618                    "  "
1619                };
1620                let name = if details.is_editing {
1621                    format!("[EDITOR: '{}']", details.filename)
1622                } else if details.is_processing {
1623                    format!("[PROCESSING: '{}']", details.filename)
1624                } else {
1625                    details.filename.clone()
1626                };
1627                let selected = if details.is_selected {
1628                    "  <== selected"
1629                } else {
1630                    ""
1631                };
1632                result.push(format!("{indent}{icon}{name}{selected}"));
1633            });
1634        });
1635
1636        result
1637    }
1638}