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    last_worktree_root_id: Option<ProjectEntryId>,
  37    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  38    selection: Option<Selection>,
  39    edit_state: Option<EditState>,
  40    filename_editor: ViewHandle<Editor>,
  41    clipboard_entry: Option<ClipboardEntry>,
  42    context_menu: ViewHandle<ContextMenu>,
  43}
  44
  45#[derive(Copy, Clone)]
  46struct Selection {
  47    worktree_id: WorktreeId,
  48    entry_id: ProjectEntryId,
  49}
  50
  51#[derive(Clone, Debug)]
  52struct EditState {
  53    worktree_id: WorktreeId,
  54    entry_id: ProjectEntryId,
  55    is_new_entry: bool,
  56    is_dir: bool,
  57    processing_filename: Option<String>,
  58}
  59
  60#[derive(Copy, Clone)]
  61pub enum ClipboardEntry {
  62    Copied {
  63        worktree_id: WorktreeId,
  64        entry_id: ProjectEntryId,
  65    },
  66    Cut {
  67        worktree_id: WorktreeId,
  68        entry_id: ProjectEntryId,
  69    },
  70}
  71
  72#[derive(Debug, PartialEq, Eq)]
  73struct EntryDetails {
  74    filename: String,
  75    depth: usize,
  76    kind: EntryKind,
  77    is_ignored: bool,
  78    is_expanded: bool,
  79    is_selected: bool,
  80    is_editing: bool,
  81    is_processing: bool,
  82    is_cut: bool,
  83}
  84
  85#[derive(Clone, PartialEq)]
  86pub struct ToggleExpanded(pub ProjectEntryId);
  87
  88#[derive(Clone, PartialEq)]
  89pub struct Open {
  90    pub entry_id: ProjectEntryId,
  91    pub change_focus: bool,
  92}
  93
  94#[derive(Clone, PartialEq)]
  95pub struct DeployContextMenu {
  96    pub position: Vector2F,
  97    pub entry_id: ProjectEntryId,
  98}
  99
 100actions!(
 101    project_panel,
 102    [
 103        ExpandSelectedEntry,
 104        CollapseSelectedEntry,
 105        AddDirectory,
 106        AddFile,
 107        Copy,
 108        CopyPath,
 109        Cut,
 110        Paste,
 111        Delete,
 112        Rename,
 113        Toggle
 114    ]
 115);
 116impl_internal_actions!(project_panel, [Open, ToggleExpanded, DeployContextMenu]);
 117
 118pub fn init(cx: &mut MutableAppContext) {
 119    cx.add_action(ProjectPanel::deploy_context_menu);
 120    cx.add_action(ProjectPanel::expand_selected_entry);
 121    cx.add_action(ProjectPanel::collapse_selected_entry);
 122    cx.add_action(ProjectPanel::toggle_expanded);
 123    cx.add_action(ProjectPanel::select_prev);
 124    cx.add_action(ProjectPanel::select_next);
 125    cx.add_action(ProjectPanel::open_entry);
 126    cx.add_action(ProjectPanel::add_file);
 127    cx.add_action(ProjectPanel::add_directory);
 128    cx.add_action(ProjectPanel::rename);
 129    cx.add_async_action(ProjectPanel::delete);
 130    cx.add_async_action(ProjectPanel::confirm);
 131    cx.add_action(ProjectPanel::cancel);
 132    cx.add_action(ProjectPanel::copy);
 133    cx.add_action(ProjectPanel::copy_path);
 134    cx.add_action(ProjectPanel::cut);
 135    cx.add_action(
 136        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
 137            this.paste(action, cx);
 138        },
 139    );
 140}
 141
 142pub enum Event {
 143    OpenedEntry {
 144        entry_id: ProjectEntryId,
 145        focus_opened_item: bool,
 146    },
 147}
 148
 149impl ProjectPanel {
 150    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 151        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 152            cx.observe(&project, |this, _, cx| {
 153                this.update_visible_entries(None, cx);
 154                cx.notify();
 155            })
 156            .detach();
 157            cx.subscribe(&project, |this, project, event, cx| match event {
 158                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 159                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 160                    {
 161                        this.expand_entry(worktree_id, *entry_id, cx);
 162                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 163                        this.autoscroll();
 164                        cx.notify();
 165                    }
 166                }
 167                project::Event::WorktreeRemoved(id) => {
 168                    this.expanded_dir_ids.remove(id);
 169                    this.update_visible_entries(None, cx);
 170                    cx.notify();
 171                }
 172                _ => {}
 173            })
 174            .detach();
 175
 176            let filename_editor = cx.add_view(|cx| {
 177                Editor::single_line(
 178                    Some(|theme| {
 179                        let mut style = theme.project_panel.filename_editor.clone();
 180                        style.container.background_color.take();
 181                        style
 182                    }),
 183                    cx,
 184                )
 185            });
 186
 187            let mut this = Self {
 188                project: project.clone(),
 189                list: Default::default(),
 190                visible_entries: Default::default(),
 191                last_worktree_root_id: Default::default(),
 192                expanded_dir_ids: Default::default(),
 193                selection: None,
 194                edit_state: None,
 195                filename_editor,
 196                clipboard_entry: None,
 197                context_menu: cx.add_view(|cx| ContextMenu::new(cx)),
 198            };
 199            this.update_visible_entries(None, cx);
 200            this
 201        });
 202        cx.subscribe(&project_panel, {
 203            let project_panel = project_panel.downgrade();
 204            move |workspace, _, event, cx| match event {
 205                &Event::OpenedEntry {
 206                    entry_id,
 207                    focus_opened_item,
 208                } => {
 209                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 210                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 211                            workspace
 212                                .open_path(
 213                                    ProjectPath {
 214                                        worktree_id: worktree.read(cx).id(),
 215                                        path: entry.path.clone(),
 216                                    },
 217                                    focus_opened_item,
 218                                    cx,
 219                                )
 220                                .detach_and_log_err(cx);
 221                            if !focus_opened_item {
 222                                if let Some(project_panel) = project_panel.upgrade(cx) {
 223                                    cx.focus(&project_panel);
 224                                }
 225                            }
 226                        }
 227                    }
 228                }
 229            }
 230        })
 231        .detach();
 232
 233        project_panel
 234    }
 235
 236    fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
 237        let project = self.project.read(cx);
 238
 239        let entry_id = action.entry_id;
 240        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 241            id
 242        } else {
 243            return;
 244        };
 245
 246        self.selection = Some(Selection {
 247            worktree_id,
 248            entry_id,
 249        });
 250
 251        let mut menu_entries = Vec::new();
 252        if let Some((worktree, entry)) = self.selected_entry(cx) {
 253            let is_root = Some(entry) == worktree.root_entry();
 254            if !project.is_remote() {
 255                menu_entries.push(ContextMenuItem::item(
 256                    "Add Folder to Project",
 257                    workspace::AddFolderToProject,
 258                ));
 259                if is_root {
 260                    menu_entries.push(ContextMenuItem::item(
 261                        "Remove from Project",
 262                        workspace::RemoveWorktreeFromProject(worktree_id),
 263                    ));
 264                }
 265            }
 266            menu_entries.push(ContextMenuItem::item("New File", AddFile));
 267            menu_entries.push(ContextMenuItem::item("New Folder", AddDirectory));
 268            menu_entries.push(ContextMenuItem::Separator);
 269            menu_entries.push(ContextMenuItem::item("Copy", Copy));
 270            menu_entries.push(ContextMenuItem::item("Copy Path", CopyPath));
 271            menu_entries.push(ContextMenuItem::item("Cut", Cut));
 272            if let Some(clipboard_entry) = self.clipboard_entry {
 273                if clipboard_entry.worktree_id() == worktree.id() {
 274                    menu_entries.push(ContextMenuItem::item("Paste", Paste));
 275                }
 276            }
 277            menu_entries.push(ContextMenuItem::Separator);
 278            menu_entries.push(ContextMenuItem::item("Rename", Rename));
 279            if !is_root {
 280                menu_entries.push(ContextMenuItem::item("Delete", Delete));
 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 project = self.project.read(cx);
 783        self.last_worktree_root_id = project
 784            .visible_worktrees(cx)
 785            .rev()
 786            .next()
 787            .and_then(|worktree| worktree.read(cx).root_entry())
 788            .map(|entry| entry.id);
 789
 790        self.visible_entries.clear();
 791        for worktree in project.visible_worktrees(cx) {
 792            let snapshot = worktree.read(cx).snapshot();
 793            let worktree_id = snapshot.id();
 794
 795            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 796                hash_map::Entry::Occupied(e) => e.into_mut(),
 797                hash_map::Entry::Vacant(e) => {
 798                    // The first time a worktree's root entry becomes available,
 799                    // mark that root entry as expanded.
 800                    if let Some(entry) = snapshot.root_entry() {
 801                        e.insert(vec![entry.id]).as_slice()
 802                    } else {
 803                        &[]
 804                    }
 805                }
 806            };
 807
 808            let mut new_entry_parent_id = None;
 809            let mut new_entry_kind = EntryKind::Dir;
 810            if let Some(edit_state) = &self.edit_state {
 811                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
 812                    new_entry_parent_id = Some(edit_state.entry_id);
 813                    new_entry_kind = if edit_state.is_dir {
 814                        EntryKind::Dir
 815                    } else {
 816                        EntryKind::File(Default::default())
 817                    };
 818                }
 819            }
 820
 821            let mut visible_worktree_entries = Vec::new();
 822            let mut entry_iter = snapshot.entries(true);
 823
 824            while let Some(entry) = entry_iter.entry() {
 825                visible_worktree_entries.push(entry.clone());
 826                if Some(entry.id) == new_entry_parent_id {
 827                    visible_worktree_entries.push(Entry {
 828                        id: NEW_ENTRY_ID,
 829                        kind: new_entry_kind,
 830                        path: entry.path.join("\0").into(),
 831                        inode: 0,
 832                        mtime: entry.mtime,
 833                        is_symlink: false,
 834                        is_ignored: false,
 835                    });
 836                }
 837                if expanded_dir_ids.binary_search(&entry.id).is_err() {
 838                    if entry_iter.advance_to_sibling() {
 839                        continue;
 840                    }
 841                }
 842                entry_iter.advance();
 843            }
 844            visible_worktree_entries.sort_by(|entry_a, entry_b| {
 845                let mut components_a = entry_a.path.components().peekable();
 846                let mut components_b = entry_b.path.components().peekable();
 847                loop {
 848                    match (components_a.next(), components_b.next()) {
 849                        (Some(component_a), Some(component_b)) => {
 850                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
 851                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
 852                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
 853                                let name_a =
 854                                    UniCase::new(component_a.as_os_str().to_string_lossy());
 855                                let name_b =
 856                                    UniCase::new(component_b.as_os_str().to_string_lossy());
 857                                name_a.cmp(&name_b)
 858                            });
 859                            if !ordering.is_eq() {
 860                                return ordering;
 861                            }
 862                        }
 863                        (Some(_), None) => break Ordering::Greater,
 864                        (None, Some(_)) => break Ordering::Less,
 865                        (None, None) => break Ordering::Equal,
 866                    }
 867                }
 868            });
 869            self.visible_entries
 870                .push((worktree_id, visible_worktree_entries));
 871        }
 872
 873        if let Some((worktree_id, entry_id)) = new_selected_entry {
 874            self.selection = Some(Selection {
 875                worktree_id,
 876                entry_id,
 877            });
 878        }
 879    }
 880
 881    fn expand_entry(
 882        &mut self,
 883        worktree_id: WorktreeId,
 884        entry_id: ProjectEntryId,
 885        cx: &mut ViewContext<Self>,
 886    ) {
 887        let project = self.project.read(cx);
 888        if let Some((worktree, expanded_dir_ids)) = project
 889            .worktree_for_id(worktree_id, cx)
 890            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 891        {
 892            let worktree = worktree.read(cx);
 893
 894            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 895                loop {
 896                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 897                        expanded_dir_ids.insert(ix, entry.id);
 898                    }
 899
 900                    if let Some(parent_entry) =
 901                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 902                    {
 903                        entry = parent_entry;
 904                    } else {
 905                        break;
 906                    }
 907                }
 908            }
 909        }
 910    }
 911
 912    fn for_each_visible_entry(
 913        &self,
 914        range: Range<usize>,
 915        cx: &mut RenderContext<ProjectPanel>,
 916        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut RenderContext<ProjectPanel>),
 917    ) {
 918        let mut ix = 0;
 919        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
 920            if ix >= range.end {
 921                return;
 922            }
 923
 924            if ix + visible_worktree_entries.len() <= range.start {
 925                ix += visible_worktree_entries.len();
 926                continue;
 927            }
 928
 929            let end_ix = range.end.min(ix + visible_worktree_entries.len());
 930            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
 931                let snapshot = worktree.read(cx).snapshot();
 932                let expanded_entry_ids = self
 933                    .expanded_dir_ids
 934                    .get(&snapshot.id())
 935                    .map(Vec::as_slice)
 936                    .unwrap_or(&[]);
 937                let root_name = OsStr::new(snapshot.root_name());
 938                for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
 939                {
 940                    let mut details = EntryDetails {
 941                        filename: entry
 942                            .path
 943                            .file_name()
 944                            .unwrap_or(root_name)
 945                            .to_string_lossy()
 946                            .to_string(),
 947                        depth: entry.path.components().count(),
 948                        kind: entry.kind,
 949                        is_ignored: entry.is_ignored,
 950                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
 951                        is_selected: self.selection.map_or(false, |e| {
 952                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
 953                        }),
 954                        is_editing: false,
 955                        is_processing: false,
 956                        is_cut: self
 957                            .clipboard_entry
 958                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
 959                    };
 960                    if let Some(edit_state) = &self.edit_state {
 961                        let is_edited_entry = if edit_state.is_new_entry {
 962                            entry.id == NEW_ENTRY_ID
 963                        } else {
 964                            entry.id == edit_state.entry_id
 965                        };
 966                        if is_edited_entry {
 967                            if let Some(processing_filename) = &edit_state.processing_filename {
 968                                details.is_processing = true;
 969                                details.filename.clear();
 970                                details.filename.push_str(&processing_filename);
 971                            } else {
 972                                if edit_state.is_new_entry {
 973                                    details.filename.clear();
 974                                }
 975                                details.is_editing = true;
 976                            }
 977                        }
 978                    }
 979
 980                    callback(entry.id, details, cx);
 981                }
 982            }
 983            ix = end_ix;
 984        }
 985    }
 986
 987    fn render_entry(
 988        entry_id: ProjectEntryId,
 989        details: EntryDetails,
 990        editor: &ViewHandle<Editor>,
 991        theme: &theme::ProjectPanel,
 992        cx: &mut RenderContext<Self>,
 993    ) -> ElementBox {
 994        let kind = details.kind;
 995        let show_editor = details.is_editing && !details.is_processing;
 996        MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
 997            let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
 998            let mut style = theme.entry.style_for(state, details.is_selected).clone();
 999            if details.is_ignored {
1000                style.text.color.fade_out(theme.ignored_entry_fade);
1001                style.icon_color.fade_out(theme.ignored_entry_fade);
1002            }
1003            if details.is_cut {
1004                style.text.color.fade_out(theme.cut_entry_fade);
1005                style.icon_color.fade_out(theme.cut_entry_fade);
1006            }
1007            let row_container_style = if show_editor {
1008                theme.filename_editor.container
1009            } else {
1010                style.container
1011            };
1012            Flex::row()
1013                .with_child(
1014                    ConstrainedBox::new(if kind == EntryKind::Dir {
1015                        if details.is_expanded {
1016                            Svg::new("icons/disclosure-open.svg")
1017                                .with_color(style.icon_color)
1018                                .boxed()
1019                        } else {
1020                            Svg::new("icons/disclosure-closed.svg")
1021                                .with_color(style.icon_color)
1022                                .boxed()
1023                        }
1024                    } else {
1025                        Empty::new().boxed()
1026                    })
1027                    .with_max_width(style.icon_size)
1028                    .with_max_height(style.icon_size)
1029                    .aligned()
1030                    .constrained()
1031                    .with_width(style.icon_size)
1032                    .boxed(),
1033                )
1034                .with_child(if show_editor {
1035                    ChildView::new(editor.clone())
1036                        .contained()
1037                        .with_margin_left(theme.entry.default.icon_spacing)
1038                        .aligned()
1039                        .left()
1040                        .flex(1.0, true)
1041                        .boxed()
1042                } else {
1043                    Label::new(details.filename, style.text.clone())
1044                        .contained()
1045                        .with_margin_left(style.icon_spacing)
1046                        .aligned()
1047                        .left()
1048                        .boxed()
1049                })
1050                .constrained()
1051                .with_height(theme.entry.default.height)
1052                .contained()
1053                .with_style(row_container_style)
1054                .with_padding_left(padding)
1055                .boxed()
1056        })
1057        .on_click(move |_, click_count, cx| {
1058            if kind == EntryKind::Dir {
1059                cx.dispatch_action(ToggleExpanded(entry_id))
1060            } else {
1061                cx.dispatch_action(Open {
1062                    entry_id,
1063                    change_focus: click_count > 1,
1064                })
1065            }
1066        })
1067        .on_right_mouse_down(move |position, cx| {
1068            cx.dispatch_action(DeployContextMenu { entry_id, position })
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        let last_worktree_root_id = self.last_worktree_root_id;
1086        Stack::new()
1087            .with_child(
1088                MouseEventHandler::new::<Tag, _, _>(0, cx, |_, cx| {
1089                    UniformList::new(
1090                        self.list.clone(),
1091                        self.visible_entries
1092                            .iter()
1093                            .map(|(_, worktree_entries)| worktree_entries.len())
1094                            .sum(),
1095                        cx,
1096                        move |this, range, items, cx| {
1097                            let theme = cx.global::<Settings>().theme.clone();
1098                            this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
1099                                items.push(Self::render_entry(
1100                                    id,
1101                                    details,
1102                                    &this.filename_editor,
1103                                    &theme.project_panel,
1104                                    cx,
1105                                ));
1106                            });
1107                        },
1108                    )
1109                    .with_padding_top(padding.top)
1110                    .with_padding_bottom(padding.bottom)
1111                    .contained()
1112                    .with_style(container_style)
1113                    .expanded()
1114                    .boxed()
1115                })
1116                .on_right_mouse_down(move |position, cx| {
1117                    // When deploying the context menu anywhere below the last project entry,
1118                    // act as if the user clicked the root of the last worktree.
1119                    if let Some(entry_id) = last_worktree_root_id {
1120                        cx.dispatch_action(DeployContextMenu { entry_id, position })
1121                    }
1122                })
1123                .boxed(),
1124            )
1125            .with_child(ChildView::new(&self.context_menu).boxed())
1126            .boxed()
1127    }
1128
1129    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
1130        let mut cx = Self::default_keymap_context();
1131        cx.set.insert("menu".into());
1132        cx
1133    }
1134}
1135
1136impl Entity for ProjectPanel {
1137    type Event = Event;
1138}
1139
1140impl workspace::sidebar::SidebarItem for ProjectPanel {
1141    fn should_show_badge(&self, _: &AppContext) -> bool {
1142        false
1143    }
1144}
1145
1146impl ClipboardEntry {
1147    fn is_cut(&self) -> bool {
1148        matches!(self, Self::Cut { .. })
1149    }
1150
1151    fn entry_id(&self) -> ProjectEntryId {
1152        match self {
1153            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1154                *entry_id
1155            }
1156        }
1157    }
1158
1159    fn worktree_id(&self) -> WorktreeId {
1160        match self {
1161            ClipboardEntry::Copied { worktree_id, .. }
1162            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1163        }
1164    }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170    use gpui::{TestAppContext, ViewHandle};
1171    use project::FakeFs;
1172    use serde_json::json;
1173    use std::{collections::HashSet, path::Path};
1174
1175    #[gpui::test]
1176    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1177        cx.foreground().forbid_parking();
1178        cx.update(|cx| {
1179            let settings = Settings::test(cx);
1180            cx.set_global(settings);
1181        });
1182
1183        let fs = FakeFs::new(cx.background());
1184        fs.insert_tree(
1185            "/root1",
1186            json!({
1187                ".dockerignore": "",
1188                ".git": {
1189                    "HEAD": "",
1190                },
1191                "a": {
1192                    "0": { "q": "", "r": "", "s": "" },
1193                    "1": { "t": "", "u": "" },
1194                    "2": { "v": "", "w": "", "x": "", "y": "" },
1195                },
1196                "b": {
1197                    "3": { "Q": "" },
1198                    "4": { "R": "", "S": "", "T": "", "U": "" },
1199                },
1200                "C": {
1201                    "5": {},
1202                    "6": { "V": "", "W": "" },
1203                    "7": { "X": "" },
1204                    "8": { "Y": {}, "Z": "" }
1205                }
1206            }),
1207        )
1208        .await;
1209        fs.insert_tree(
1210            "/root2",
1211            json!({
1212                "d": {
1213                    "9": ""
1214                },
1215                "e": {}
1216            }),
1217        )
1218        .await;
1219
1220        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1221        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
1222        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1223        assert_eq!(
1224            visible_entries_as_strings(&panel, 0..50, cx),
1225            &[
1226                "v root1",
1227                "    > .git",
1228                "    > a",
1229                "    > b",
1230                "    > C",
1231                "      .dockerignore",
1232                "v root2",
1233                "    > d",
1234                "    > e",
1235            ]
1236        );
1237
1238        toggle_expand_dir(&panel, "root1/b", cx);
1239        assert_eq!(
1240            visible_entries_as_strings(&panel, 0..50, cx),
1241            &[
1242                "v root1",
1243                "    > .git",
1244                "    > a",
1245                "    v b  <== selected",
1246                "        > 3",
1247                "        > 4",
1248                "    > C",
1249                "      .dockerignore",
1250                "v root2",
1251                "    > d",
1252                "    > e",
1253            ]
1254        );
1255
1256        assert_eq!(
1257            visible_entries_as_strings(&panel, 6..9, cx),
1258            &[
1259                //
1260                "    > C",
1261                "      .dockerignore",
1262                "v root2",
1263            ]
1264        );
1265    }
1266
1267    #[gpui::test(iterations = 30)]
1268    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1269        cx.foreground().forbid_parking();
1270        cx.update(|cx| {
1271            let settings = Settings::test(cx);
1272            cx.set_global(settings);
1273        });
1274
1275        let fs = FakeFs::new(cx.background());
1276        fs.insert_tree(
1277            "/root1",
1278            json!({
1279                ".dockerignore": "",
1280                ".git": {
1281                    "HEAD": "",
1282                },
1283                "a": {
1284                    "0": { "q": "", "r": "", "s": "" },
1285                    "1": { "t": "", "u": "" },
1286                    "2": { "v": "", "w": "", "x": "", "y": "" },
1287                },
1288                "b": {
1289                    "3": { "Q": "" },
1290                    "4": { "R": "", "S": "", "T": "", "U": "" },
1291                },
1292                "C": {
1293                    "5": {},
1294                    "6": { "V": "", "W": "" },
1295                    "7": { "X": "" },
1296                    "8": { "Y": {}, "Z": "" }
1297                }
1298            }),
1299        )
1300        .await;
1301        fs.insert_tree(
1302            "/root2",
1303            json!({
1304                "d": {
1305                    "9": ""
1306                },
1307                "e": {}
1308            }),
1309        )
1310        .await;
1311
1312        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1313        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
1314        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1315
1316        select_path(&panel, "root1", cx);
1317        assert_eq!(
1318            visible_entries_as_strings(&panel, 0..10, cx),
1319            &[
1320                "v root1  <== selected",
1321                "    > .git",
1322                "    > a",
1323                "    > b",
1324                "    > C",
1325                "      .dockerignore",
1326                "v root2",
1327                "    > d",
1328                "    > e",
1329            ]
1330        );
1331
1332        // Add a file with the root folder selected. The filename editor is placed
1333        // before the first file in the root folder.
1334        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1335        assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1336        assert_eq!(
1337            visible_entries_as_strings(&panel, 0..10, cx),
1338            &[
1339                "v root1",
1340                "    > .git",
1341                "    > a",
1342                "    > b",
1343                "    > C",
1344                "      [EDITOR: '']  <== selected",
1345                "      .dockerignore",
1346                "v root2",
1347                "    > d",
1348                "    > e",
1349            ]
1350        );
1351
1352        let confirm = panel.update(cx, |panel, cx| {
1353            panel
1354                .filename_editor
1355                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1356            panel.confirm(&Confirm, cx).unwrap()
1357        });
1358        assert_eq!(
1359            visible_entries_as_strings(&panel, 0..10, cx),
1360            &[
1361                "v root1",
1362                "    > .git",
1363                "    > a",
1364                "    > b",
1365                "    > C",
1366                "      [PROCESSING: 'the-new-filename']  <== selected",
1367                "      .dockerignore",
1368                "v root2",
1369                "    > d",
1370                "    > e",
1371            ]
1372        );
1373
1374        confirm.await.unwrap();
1375        assert_eq!(
1376            visible_entries_as_strings(&panel, 0..10, cx),
1377            &[
1378                "v root1",
1379                "    > .git",
1380                "    > a",
1381                "    > b",
1382                "    > C",
1383                "      .dockerignore",
1384                "      the-new-filename  <== selected",
1385                "v root2",
1386                "    > d",
1387                "    > e",
1388            ]
1389        );
1390
1391        select_path(&panel, "root1/b", cx);
1392        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1393        assert_eq!(
1394            visible_entries_as_strings(&panel, 0..10, cx),
1395            &[
1396                "v root1",
1397                "    > .git",
1398                "    > a",
1399                "    v b",
1400                "        > 3",
1401                "        > 4",
1402                "          [EDITOR: '']  <== selected",
1403                "    > C",
1404                "      .dockerignore",
1405                "      the-new-filename",
1406            ]
1407        );
1408
1409        panel
1410            .update(cx, |panel, cx| {
1411                panel
1412                    .filename_editor
1413                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1414                panel.confirm(&Confirm, cx).unwrap()
1415            })
1416            .await
1417            .unwrap();
1418        assert_eq!(
1419            visible_entries_as_strings(&panel, 0..10, cx),
1420            &[
1421                "v root1",
1422                "    > .git",
1423                "    > a",
1424                "    v b",
1425                "        > 3",
1426                "        > 4",
1427                "          another-filename  <== selected",
1428                "    > C",
1429                "      .dockerignore",
1430                "      the-new-filename",
1431            ]
1432        );
1433
1434        select_path(&panel, "root1/b/another-filename", cx);
1435        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1436        assert_eq!(
1437            visible_entries_as_strings(&panel, 0..10, cx),
1438            &[
1439                "v root1",
1440                "    > .git",
1441                "    > a",
1442                "    v b",
1443                "        > 3",
1444                "        > 4",
1445                "          [EDITOR: 'another-filename']  <== selected",
1446                "    > C",
1447                "      .dockerignore",
1448                "      the-new-filename",
1449            ]
1450        );
1451
1452        let confirm = panel.update(cx, |panel, cx| {
1453            panel
1454                .filename_editor
1455                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1456            panel.confirm(&Confirm, cx).unwrap()
1457        });
1458        assert_eq!(
1459            visible_entries_as_strings(&panel, 0..10, cx),
1460            &[
1461                "v root1",
1462                "    > .git",
1463                "    > a",
1464                "    v b",
1465                "        > 3",
1466                "        > 4",
1467                "          [PROCESSING: 'a-different-filename']  <== selected",
1468                "    > C",
1469                "      .dockerignore",
1470                "      the-new-filename",
1471            ]
1472        );
1473
1474        confirm.await.unwrap();
1475        assert_eq!(
1476            visible_entries_as_strings(&panel, 0..10, cx),
1477            &[
1478                "v root1",
1479                "    > .git",
1480                "    > a",
1481                "    v b",
1482                "        > 3",
1483                "        > 4",
1484                "          a-different-filename  <== selected",
1485                "    > C",
1486                "      .dockerignore",
1487                "      the-new-filename",
1488            ]
1489        );
1490
1491        panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
1492        assert_eq!(
1493            visible_entries_as_strings(&panel, 0..10, cx),
1494            &[
1495                "v root1",
1496                "    > .git",
1497                "    > a",
1498                "    v b",
1499                "        > [EDITOR: '']  <== selected",
1500                "        > 3",
1501                "        > 4",
1502                "          a-different-filename",
1503                "    > C",
1504                "      .dockerignore",
1505            ]
1506        );
1507
1508        let confirm = panel.update(cx, |panel, cx| {
1509            panel
1510                .filename_editor
1511                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1512            panel.confirm(&Confirm, cx).unwrap()
1513        });
1514        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1515        assert_eq!(
1516            visible_entries_as_strings(&panel, 0..10, cx),
1517            &[
1518                "v root1",
1519                "    > .git",
1520                "    > a",
1521                "    v b",
1522                "        > [PROCESSING: 'new-dir']",
1523                "        > 3  <== selected",
1524                "        > 4",
1525                "          a-different-filename",
1526                "    > C",
1527                "      .dockerignore",
1528            ]
1529        );
1530
1531        confirm.await.unwrap();
1532        assert_eq!(
1533            visible_entries_as_strings(&panel, 0..10, cx),
1534            &[
1535                "v root1",
1536                "    > .git",
1537                "    > a",
1538                "    v b",
1539                "        > 3  <== selected",
1540                "        > 4",
1541                "        > new-dir",
1542                "          a-different-filename",
1543                "    > C",
1544                "      .dockerignore",
1545            ]
1546        );
1547    }
1548
1549    fn toggle_expand_dir(
1550        panel: &ViewHandle<ProjectPanel>,
1551        path: impl AsRef<Path>,
1552        cx: &mut TestAppContext,
1553    ) {
1554        let path = path.as_ref();
1555        panel.update(cx, |panel, cx| {
1556            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1557                let worktree = worktree.read(cx);
1558                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1559                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1560                    panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1561                    return;
1562                }
1563            }
1564            panic!("no worktree for path {:?}", path);
1565        });
1566    }
1567
1568    fn select_path(
1569        panel: &ViewHandle<ProjectPanel>,
1570        path: impl AsRef<Path>,
1571        cx: &mut TestAppContext,
1572    ) {
1573        let path = path.as_ref();
1574        panel.update(cx, |panel, cx| {
1575            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1576                let worktree = worktree.read(cx);
1577                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1578                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1579                    panel.selection = Some(Selection {
1580                        worktree_id: worktree.id(),
1581                        entry_id,
1582                    });
1583                    return;
1584                }
1585            }
1586            panic!("no worktree for path {:?}", path);
1587        });
1588    }
1589
1590    fn visible_entries_as_strings(
1591        panel: &ViewHandle<ProjectPanel>,
1592        range: Range<usize>,
1593        cx: &mut TestAppContext,
1594    ) -> Vec<String> {
1595        let mut result = Vec::new();
1596        let mut project_entries = HashSet::new();
1597        let mut has_editor = false;
1598        cx.render(panel, |panel, cx| {
1599            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1600                if details.is_editing {
1601                    assert!(!has_editor, "duplicate editor entry");
1602                    has_editor = true;
1603                } else {
1604                    assert!(
1605                        project_entries.insert(project_entry),
1606                        "duplicate project entry {:?} {:?}",
1607                        project_entry,
1608                        details
1609                    );
1610                }
1611
1612                let indent = "    ".repeat(details.depth);
1613                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1614                    if details.is_expanded {
1615                        "v "
1616                    } else {
1617                        "> "
1618                    }
1619                } else {
1620                    "  "
1621                };
1622                let name = if details.is_editing {
1623                    format!("[EDITOR: '{}']", details.filename)
1624                } else if details.is_processing {
1625                    format!("[PROCESSING: '{}']", details.filename)
1626                } else {
1627                    details.filename.clone()
1628                };
1629                let selected = if details.is_selected {
1630                    "  <== selected"
1631                } else {
1632                    ""
1633                };
1634                result.push(format!("{indent}{icon}{name}{selected}"));
1635            });
1636        });
1637
1638        result
1639    }
1640}