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