project_panel.rs

   1use editor::{Cancel, Editor};
   2use gpui::{
   3    actions,
   4    anyhow::Result,
   5    elements::{
   6        ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement,
   7        ScrollTarget, Svg, UniformList, UniformListState,
   8    },
   9    impl_internal_actions, keymap,
  10    platform::CursorStyle,
  11    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task, View,
  12    ViewContext, ViewHandle, WeakViewHandle,
  13};
  14use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
  15use settings::Settings;
  16use std::{
  17    cmp::Ordering,
  18    collections::{hash_map, HashMap},
  19    ffi::OsStr,
  20    ops::Range,
  21};
  22use unicase::UniCase;
  23use workspace::{
  24    menu::{Confirm, SelectNext, SelectPrev},
  25    Workspace,
  26};
  27
  28const NEW_FILE_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  29
  30pub struct ProjectPanel {
  31    project: ModelHandle<Project>,
  32    list: UniformListState,
  33    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  34    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  35    selection: Option<Selection>,
  36    edit_state: Option<EditState>,
  37    filename_editor: ViewHandle<Editor>,
  38    handle: WeakViewHandle<Self>,
  39}
  40
  41#[derive(Copy, Clone)]
  42struct Selection {
  43    worktree_id: WorktreeId,
  44    entry_id: ProjectEntryId,
  45}
  46
  47#[derive(Copy, Clone, Debug)]
  48struct EditState {
  49    worktree_id: WorktreeId,
  50    entry_id: ProjectEntryId,
  51    new_file: bool,
  52}
  53
  54#[derive(Debug, PartialEq, Eq)]
  55struct EntryDetails {
  56    filename: String,
  57    depth: usize,
  58    kind: EntryKind,
  59    is_expanded: bool,
  60    is_selected: bool,
  61    is_editing: bool,
  62}
  63
  64#[derive(Clone)]
  65pub struct ToggleExpanded(pub ProjectEntryId);
  66
  67#[derive(Clone)]
  68pub struct Open(pub ProjectEntryId);
  69
  70actions!(
  71    project_panel,
  72    [ExpandSelectedEntry, CollapseSelectedEntry, AddFile, Rename]
  73);
  74impl_internal_actions!(project_panel, [Open, ToggleExpanded]);
  75
  76pub fn init(cx: &mut MutableAppContext) {
  77    cx.add_action(ProjectPanel::expand_selected_entry);
  78    cx.add_action(ProjectPanel::collapse_selected_entry);
  79    cx.add_action(ProjectPanel::toggle_expanded);
  80    cx.add_action(ProjectPanel::select_prev);
  81    cx.add_action(ProjectPanel::select_next);
  82    cx.add_action(ProjectPanel::open_entry);
  83    cx.add_action(ProjectPanel::add_file);
  84    cx.add_action(ProjectPanel::rename);
  85    cx.add_async_action(ProjectPanel::confirm);
  86    cx.add_action(ProjectPanel::cancel);
  87}
  88
  89pub enum Event {
  90    OpenedEntry(ProjectEntryId),
  91}
  92
  93impl ProjectPanel {
  94    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
  95        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
  96            cx.observe(&project, |this, _, cx| {
  97                this.update_visible_entries(None, cx);
  98                cx.notify();
  99            })
 100            .detach();
 101            cx.subscribe(&project, |this, project, event, cx| match event {
 102                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 103                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 104                    {
 105                        this.expand_entry(worktree_id, *entry_id, cx);
 106                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 107                        this.autoscroll();
 108                        cx.notify();
 109                    }
 110                }
 111                project::Event::WorktreeRemoved(id) => {
 112                    this.expanded_dir_ids.remove(id);
 113                    this.update_visible_entries(None, cx);
 114                    cx.notify();
 115                }
 116                _ => {}
 117            })
 118            .detach();
 119
 120            let filename_editor = cx.add_view(|cx| {
 121                Editor::single_line(
 122                    Some(|theme| {
 123                        let mut style = theme.project_panel.filename_editor.clone();
 124                        style.container.background_color.take();
 125                        style
 126                    }),
 127                    cx,
 128                )
 129            });
 130            cx.subscribe(&filename_editor, |this, _, event, cx| {
 131                if let editor::Event::Blurred = event {
 132                    this.editor_blurred(cx);
 133                }
 134            })
 135            .detach();
 136
 137            let mut this = Self {
 138                project: project.clone(),
 139                list: Default::default(),
 140                visible_entries: Default::default(),
 141                expanded_dir_ids: Default::default(),
 142                selection: None,
 143                edit_state: None,
 144                filename_editor,
 145                handle: cx.weak_handle(),
 146            };
 147            this.update_visible_entries(None, cx);
 148            this
 149        });
 150        cx.subscribe(&project_panel, move |workspace, _, event, cx| match event {
 151            &Event::OpenedEntry(entry_id) => {
 152                if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 153                    if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 154                        workspace
 155                            .open_path(
 156                                ProjectPath {
 157                                    worktree_id: worktree.read(cx).id(),
 158                                    path: entry.path.clone(),
 159                                },
 160                                cx,
 161                            )
 162                            .detach_and_log_err(cx);
 163                    }
 164                }
 165            }
 166        })
 167        .detach();
 168
 169        project_panel
 170    }
 171
 172    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 173        if let Some((worktree, entry)) = self.selected_entry(cx) {
 174            let expanded_dir_ids =
 175                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 176                    expanded_dir_ids
 177                } else {
 178                    return;
 179                };
 180
 181            if entry.is_dir() {
 182                match expanded_dir_ids.binary_search(&entry.id) {
 183                    Ok(_) => self.select_next(&SelectNext, cx),
 184                    Err(ix) => {
 185                        expanded_dir_ids.insert(ix, entry.id);
 186                        self.update_visible_entries(None, cx);
 187                        cx.notify();
 188                    }
 189                }
 190            } else {
 191                let event = Event::OpenedEntry(entry.id);
 192                cx.emit(event);
 193            }
 194        }
 195    }
 196
 197    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 198        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 199            let expanded_dir_ids =
 200                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 201                    expanded_dir_ids
 202                } else {
 203                    return;
 204                };
 205
 206            loop {
 207                match expanded_dir_ids.binary_search(&entry.id) {
 208                    Ok(ix) => {
 209                        expanded_dir_ids.remove(ix);
 210                        self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
 211                        cx.notify();
 212                        break;
 213                    }
 214                    Err(_) => {
 215                        if let Some(parent_entry) =
 216                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 217                        {
 218                            entry = parent_entry;
 219                        } else {
 220                            break;
 221                        }
 222                    }
 223                }
 224            }
 225        }
 226    }
 227
 228    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
 229        let entry_id = action.0;
 230        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 231            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 232                match expanded_dir_ids.binary_search(&entry_id) {
 233                    Ok(ix) => {
 234                        expanded_dir_ids.remove(ix);
 235                    }
 236                    Err(ix) => {
 237                        expanded_dir_ids.insert(ix, entry_id);
 238                    }
 239                }
 240                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 241                cx.focus_self();
 242            }
 243        }
 244    }
 245
 246    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 247        if let Some(selection) = self.selection {
 248            let (mut worktree_ix, mut entry_ix, _) =
 249                self.index_for_selection(selection).unwrap_or_default();
 250            if entry_ix > 0 {
 251                entry_ix -= 1;
 252            } else {
 253                if worktree_ix > 0 {
 254                    worktree_ix -= 1;
 255                    entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 256                } else {
 257                    return;
 258                }
 259            }
 260
 261            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 262            self.selection = Some(Selection {
 263                worktree_id: *worktree_id,
 264                entry_id: worktree_entries[entry_ix].id,
 265            });
 266            self.autoscroll();
 267            cx.notify();
 268        } else {
 269            self.select_first(cx);
 270        }
 271    }
 272
 273    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 274        let edit_state = self.edit_state.take()?;
 275        cx.focus_self();
 276
 277        let worktree = self
 278            .project
 279            .read(cx)
 280            .worktree_for_id(edit_state.worktree_id, cx)?;
 281        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 282        let filename = self.filename_editor.read(cx).text(cx);
 283
 284        if edit_state.new_file {
 285            let new_path = entry.path.join(filename);
 286            let save = self.project.update(cx, |project, cx| {
 287                project.create_file((edit_state.worktree_id, new_path), cx)
 288            })?;
 289            Some(cx.spawn(|this, mut cx| async move {
 290                let new_entry = save.await?;
 291                this.update(&mut cx, |this, cx| {
 292                    this.update_visible_entries(Some((edit_state.worktree_id, new_entry.id)), cx);
 293                    cx.notify();
 294                });
 295                Ok(())
 296            }))
 297        } else {
 298            let old_path = entry.path.clone();
 299            let new_path = if let Some(parent) = old_path.parent() {
 300                parent.join(filename)
 301            } else {
 302                filename.into()
 303            };
 304
 305            let rename = self.project.update(cx, |project, cx| {
 306                project.rename_entry(entry.id, new_path, cx)
 307            })?;
 308
 309            Some(cx.spawn(|this, mut cx| async move {
 310                let new_entry = rename.await?;
 311                this.update(&mut cx, |this, cx| {
 312                    this.update_visible_entries(Some((edit_state.worktree_id, new_entry.id)), cx);
 313                    cx.notify();
 314                });
 315                Ok(())
 316            }))
 317        }
 318    }
 319
 320    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 321        self.edit_state = None;
 322        self.update_visible_entries(None, cx);
 323        cx.focus_self();
 324        cx.notify();
 325    }
 326
 327    fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
 328        cx.emit(Event::OpenedEntry(action.0));
 329    }
 330
 331    fn add_file(&mut self, _: &AddFile, cx: &mut ViewContext<Self>) {
 332        if let Some(Selection {
 333            worktree_id,
 334            entry_id,
 335        }) = self.selection
 336        {
 337            let directory_id;
 338            if let Some((worktree, expanded_dir_ids)) = self
 339                .project
 340                .read(cx)
 341                .worktree_for_id(worktree_id, cx)
 342                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 343            {
 344                let worktree = worktree.read(cx);
 345                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 346                    loop {
 347                        if entry.is_dir() {
 348                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 349                                expanded_dir_ids.insert(ix, entry.id);
 350                            }
 351                            directory_id = entry.id;
 352                            break;
 353                        } else {
 354                            if let Some(parent_path) = entry.path.parent() {
 355                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 356                                    entry = parent_entry;
 357                                    continue;
 358                                }
 359                            }
 360                            return;
 361                        }
 362                    }
 363                } else {
 364                    return;
 365                };
 366            } else {
 367                return;
 368            };
 369
 370            self.edit_state = Some(EditState {
 371                worktree_id,
 372                entry_id: directory_id,
 373                new_file: true,
 374            });
 375            self.filename_editor
 376                .update(cx, |editor, cx| editor.clear(cx));
 377            cx.focus(&self.filename_editor);
 378            self.update_visible_entries(None, cx);
 379            cx.notify();
 380        }
 381    }
 382
 383    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 384        if let Some(Selection {
 385            worktree_id,
 386            entry_id,
 387        }) = self.selection
 388        {
 389            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 390                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 391                    self.edit_state = Some(EditState {
 392                        worktree_id,
 393                        entry_id,
 394                        new_file: false,
 395                    });
 396                    let filename = entry
 397                        .path
 398                        .file_name()
 399                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
 400                    self.filename_editor.update(cx, |editor, cx| {
 401                        editor.set_text(filename, cx);
 402                        editor.select_all(&Default::default(), cx);
 403                    });
 404                    cx.focus(&self.filename_editor);
 405                    self.update_visible_entries(None, cx);
 406                    cx.notify();
 407                }
 408            }
 409        }
 410    }
 411
 412    fn editor_blurred(&mut self, cx: &mut ViewContext<Self>) {
 413        self.edit_state = None;
 414        self.update_visible_entries(None, cx);
 415        cx.focus_self();
 416        cx.notify();
 417    }
 418
 419    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 420        if let Some(selection) = self.selection {
 421            let (mut worktree_ix, mut entry_ix, _) =
 422                self.index_for_selection(selection).unwrap_or_default();
 423            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 424                if entry_ix + 1 < worktree_entries.len() {
 425                    entry_ix += 1;
 426                } else {
 427                    worktree_ix += 1;
 428                    entry_ix = 0;
 429                }
 430            }
 431
 432            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 433                if let Some(entry) = worktree_entries.get(entry_ix) {
 434                    self.selection = Some(Selection {
 435                        worktree_id: *worktree_id,
 436                        entry_id: entry.id,
 437                    });
 438                    self.autoscroll();
 439                    cx.notify();
 440                }
 441            }
 442        } else {
 443            self.select_first(cx);
 444        }
 445    }
 446
 447    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 448        let worktree = self
 449            .visible_entries
 450            .first()
 451            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 452        if let Some(worktree) = worktree {
 453            let worktree = worktree.read(cx);
 454            let worktree_id = worktree.id();
 455            if let Some(root_entry) = worktree.root_entry() {
 456                self.selection = Some(Selection {
 457                    worktree_id,
 458                    entry_id: root_entry.id,
 459                });
 460                self.autoscroll();
 461                cx.notify();
 462            }
 463        }
 464    }
 465
 466    fn autoscroll(&mut self) {
 467        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 468            self.list.scroll_to(ScrollTarget::Show(index));
 469        }
 470    }
 471
 472    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
 473        let mut worktree_index = 0;
 474        let mut entry_index = 0;
 475        let mut visible_entries_index = 0;
 476        for (worktree_id, worktree_entries) in &self.visible_entries {
 477            if *worktree_id == selection.worktree_id {
 478                for entry in worktree_entries {
 479                    if entry.id == selection.entry_id {
 480                        return Some((worktree_index, entry_index, visible_entries_index));
 481                    } else {
 482                        visible_entries_index += 1;
 483                        entry_index += 1;
 484                    }
 485                }
 486                break;
 487            } else {
 488                visible_entries_index += worktree_entries.len();
 489            }
 490            worktree_index += 1;
 491        }
 492        None
 493    }
 494
 495    fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
 496        let selection = self.selection?;
 497        let project = self.project.read(cx);
 498        let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
 499        Some((worktree, worktree.entry_for_id(selection.entry_id)?))
 500    }
 501
 502    fn update_visible_entries(
 503        &mut self,
 504        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 505        cx: &mut ViewContext<Self>,
 506    ) {
 507        let worktrees = self
 508            .project
 509            .read(cx)
 510            .worktrees(cx)
 511            .filter(|worktree| worktree.read(cx).is_visible());
 512        self.visible_entries.clear();
 513
 514        for worktree in worktrees {
 515            let snapshot = worktree.read(cx).snapshot();
 516            let worktree_id = snapshot.id();
 517
 518            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 519                hash_map::Entry::Occupied(e) => e.into_mut(),
 520                hash_map::Entry::Vacant(e) => {
 521                    // The first time a worktree's root entry becomes available,
 522                    // mark that root entry as expanded.
 523                    if let Some(entry) = snapshot.root_entry() {
 524                        e.insert(vec![entry.id]).as_slice()
 525                    } else {
 526                        &[]
 527                    }
 528                }
 529            };
 530
 531            let new_file_parent_id = self.edit_state.and_then(|edit_state| {
 532                if edit_state.worktree_id == worktree_id && edit_state.new_file {
 533                    Some(edit_state.entry_id)
 534                } else {
 535                    None
 536                }
 537            });
 538
 539            let mut visible_worktree_entries = Vec::new();
 540            let mut entry_iter = snapshot.entries(false);
 541            while let Some(entry) = entry_iter.entry() {
 542                visible_worktree_entries.push(entry.clone());
 543                if Some(entry.id) == new_file_parent_id {
 544                    visible_worktree_entries.push(Entry {
 545                        id: NEW_FILE_ENTRY_ID,
 546                        kind: project::EntryKind::File(Default::default()),
 547                        path: entry.path.join("\0").into(),
 548                        inode: 0,
 549                        mtime: entry.mtime,
 550                        is_symlink: false,
 551                        is_ignored: false,
 552                    });
 553                }
 554                if expanded_dir_ids.binary_search(&entry.id).is_err() {
 555                    if entry_iter.advance_to_sibling() {
 556                        continue;
 557                    }
 558                }
 559                entry_iter.advance();
 560            }
 561            visible_worktree_entries.sort_by(|entry_a, entry_b| {
 562                let mut components_a = entry_a.path.components().peekable();
 563                let mut components_b = entry_b.path.components().peekable();
 564                loop {
 565                    match (components_a.next(), components_b.next()) {
 566                        (Some(component_a), Some(component_b)) => {
 567                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
 568                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
 569                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
 570                                let name_a =
 571                                    UniCase::new(component_a.as_os_str().to_string_lossy());
 572                                let name_b =
 573                                    UniCase::new(component_b.as_os_str().to_string_lossy());
 574                                name_a.cmp(&name_b)
 575                            });
 576                            if !ordering.is_eq() {
 577                                return ordering;
 578                            }
 579                        }
 580                        (Some(_), None) => break Ordering::Greater,
 581                        (None, Some(_)) => break Ordering::Less,
 582                        (None, None) => break Ordering::Equal,
 583                    }
 584                }
 585            });
 586            self.visible_entries
 587                .push((worktree_id, visible_worktree_entries));
 588        }
 589
 590        if let Some((worktree_id, entry_id)) = new_selected_entry {
 591            self.selection = Some(Selection {
 592                worktree_id,
 593                entry_id,
 594            });
 595        }
 596    }
 597
 598    fn expand_entry(
 599        &mut self,
 600        worktree_id: WorktreeId,
 601        entry_id: ProjectEntryId,
 602        cx: &mut ViewContext<Self>,
 603    ) {
 604        let project = self.project.read(cx);
 605        if let Some((worktree, expanded_dir_ids)) = project
 606            .worktree_for_id(worktree_id, cx)
 607            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 608        {
 609            let worktree = worktree.read(cx);
 610
 611            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 612                loop {
 613                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 614                        expanded_dir_ids.insert(ix, entry.id);
 615                    }
 616
 617                    if let Some(parent_entry) =
 618                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 619                    {
 620                        entry = parent_entry;
 621                    } else {
 622                        break;
 623                    }
 624                }
 625            }
 626        }
 627    }
 628
 629    fn for_each_visible_entry(
 630        &self,
 631        range: Range<usize>,
 632        cx: &mut ViewContext<ProjectPanel>,
 633        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
 634    ) {
 635        let mut ix = 0;
 636        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
 637            if ix >= range.end {
 638                return;
 639            }
 640
 641            if ix + visible_worktree_entries.len() <= range.start {
 642                ix += visible_worktree_entries.len();
 643                continue;
 644            }
 645
 646            let end_ix = range.end.min(ix + visible_worktree_entries.len());
 647            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
 648                let snapshot = worktree.read(cx).snapshot();
 649                let expanded_entry_ids = self
 650                    .expanded_dir_ids
 651                    .get(&snapshot.id())
 652                    .map(Vec::as_slice)
 653                    .unwrap_or(&[]);
 654                let root_name = OsStr::new(snapshot.root_name());
 655                for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
 656                {
 657                    let mut details = EntryDetails {
 658                        filename: entry
 659                            .path
 660                            .file_name()
 661                            .unwrap_or(root_name)
 662                            .to_string_lossy()
 663                            .to_string(),
 664                        depth: entry.path.components().count(),
 665                        kind: entry.kind,
 666                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
 667                        is_selected: self.selection.map_or(false, |e| {
 668                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
 669                        }),
 670                        is_editing: false,
 671                    };
 672                    if let Some(edit_state) = self.edit_state {
 673                        if edit_state.new_file {
 674                            if entry.id == NEW_FILE_ENTRY_ID {
 675                                details.is_editing = true;
 676                                details.filename.clear();
 677                            }
 678                        } else {
 679                            if entry.id == edit_state.entry_id {
 680                                details.is_editing = true;
 681                            }
 682                        };
 683                    }
 684                    callback(entry.id, details, cx);
 685                }
 686            }
 687            ix = end_ix;
 688        }
 689    }
 690
 691    fn render_entry(
 692        entry_id: ProjectEntryId,
 693        details: EntryDetails,
 694        editor: &ViewHandle<Editor>,
 695        theme: &theme::ProjectPanel,
 696        cx: &mut ViewContext<Self>,
 697    ) -> ElementBox {
 698        let kind = details.kind;
 699        MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
 700            let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
 701            let style = theme.entry.style_for(state, details.is_selected);
 702            let row_container_style = if details.is_editing {
 703                theme.filename_editor.container
 704            } else {
 705                style.container
 706            };
 707            Flex::row()
 708                .with_child(
 709                    ConstrainedBox::new(if kind == EntryKind::Dir {
 710                        if details.is_expanded {
 711                            Svg::new("icons/disclosure-open.svg")
 712                                .with_color(style.icon_color)
 713                                .boxed()
 714                        } else {
 715                            Svg::new("icons/disclosure-closed.svg")
 716                                .with_color(style.icon_color)
 717                                .boxed()
 718                        }
 719                    } else {
 720                        Empty::new().boxed()
 721                    })
 722                    .with_max_width(style.icon_size)
 723                    .with_max_height(style.icon_size)
 724                    .aligned()
 725                    .constrained()
 726                    .with_width(style.icon_size)
 727                    .boxed(),
 728                )
 729                .with_child(if details.is_editing {
 730                    ChildView::new(editor.clone())
 731                        .contained()
 732                        .with_margin_left(theme.entry.default.icon_spacing)
 733                        .aligned()
 734                        .left()
 735                        .flex(1.0, true)
 736                        .boxed()
 737                } else {
 738                    Label::new(details.filename, style.text.clone())
 739                        .contained()
 740                        .with_margin_left(style.icon_spacing)
 741                        .aligned()
 742                        .left()
 743                        .boxed()
 744                })
 745                .constrained()
 746                .with_height(theme.entry.default.height)
 747                .contained()
 748                .with_style(row_container_style)
 749                .with_padding_left(padding)
 750                .boxed()
 751        })
 752        .on_click(move |cx| {
 753            if kind == EntryKind::Dir {
 754                cx.dispatch_action(ToggleExpanded(entry_id))
 755            } else {
 756                cx.dispatch_action(Open(entry_id))
 757            }
 758        })
 759        .with_cursor_style(CursorStyle::PointingHand)
 760        .boxed()
 761    }
 762}
 763
 764impl View for ProjectPanel {
 765    fn ui_name() -> &'static str {
 766        "ProjectPanel"
 767    }
 768
 769    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
 770        let theme = &cx.global::<Settings>().theme.project_panel;
 771        let mut container_style = theme.container;
 772        let padding = std::mem::take(&mut container_style.padding);
 773        let handle = self.handle.clone();
 774        UniformList::new(
 775            self.list.clone(),
 776            self.visible_entries
 777                .iter()
 778                .map(|(_, worktree_entries)| worktree_entries.len())
 779                .sum(),
 780            move |range, items, cx| {
 781                let theme = cx.global::<Settings>().theme.clone();
 782                let this = handle.upgrade(cx).unwrap();
 783                this.update(cx.app, |this, cx| {
 784                    this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
 785                        items.push(Self::render_entry(
 786                            id,
 787                            details,
 788                            &this.filename_editor,
 789                            &theme.project_panel,
 790                            cx,
 791                        ));
 792                    });
 793                })
 794            },
 795        )
 796        .with_padding_top(padding.top)
 797        .with_padding_bottom(padding.bottom)
 798        .contained()
 799        .with_style(container_style)
 800        .boxed()
 801    }
 802
 803    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
 804        let mut cx = Self::default_keymap_context();
 805        cx.set.insert("menu".into());
 806        cx
 807    }
 808}
 809
 810impl Entity for ProjectPanel {
 811    type Event = Event;
 812}
 813
 814#[cfg(test)]
 815mod tests {
 816    use super::*;
 817    use gpui::{TestAppContext, ViewHandle};
 818    use project::FakeFs;
 819    use serde_json::json;
 820    use std::{collections::HashSet, path::Path};
 821    use workspace::WorkspaceParams;
 822
 823    #[gpui::test]
 824    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
 825        cx.foreground().forbid_parking();
 826
 827        let fs = FakeFs::new(cx.background());
 828        fs.insert_tree(
 829            "/root1",
 830            json!({
 831                ".dockerignore": "",
 832                ".git": {
 833                    "HEAD": "",
 834                },
 835                "a": {
 836                    "0": { "q": "", "r": "", "s": "" },
 837                    "1": { "t": "", "u": "" },
 838                    "2": { "v": "", "w": "", "x": "", "y": "" },
 839                },
 840                "b": {
 841                    "3": { "Q": "" },
 842                    "4": { "R": "", "S": "", "T": "", "U": "" },
 843                },
 844                "C": {
 845                    "5": {},
 846                    "6": { "V": "", "W": "" },
 847                    "7": { "X": "" },
 848                    "8": { "Y": {}, "Z": "" }
 849                }
 850            }),
 851        )
 852        .await;
 853        fs.insert_tree(
 854            "/root2",
 855            json!({
 856                "d": {
 857                    "9": ""
 858                },
 859                "e": {}
 860            }),
 861        )
 862        .await;
 863
 864        let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
 865        let params = cx.update(WorkspaceParams::test);
 866        let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
 867        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
 868        assert_eq!(
 869            visible_entries_as_strings(&panel, 0..50, cx),
 870            &[
 871                "v root1",
 872                "    > a",
 873                "    > b",
 874                "    > C",
 875                "      .dockerignore",
 876                "v root2",
 877                "    > d",
 878                "    > e",
 879            ]
 880        );
 881
 882        toggle_expand_dir(&panel, "root1/b", cx);
 883        assert_eq!(
 884            visible_entries_as_strings(&panel, 0..50, cx),
 885            &[
 886                "v root1",
 887                "    > a",
 888                "    v b  <== selected",
 889                "        > 3",
 890                "        > 4",
 891                "    > C",
 892                "      .dockerignore",
 893                "v root2",
 894                "    > d",
 895                "    > e",
 896            ]
 897        );
 898
 899        assert_eq!(
 900            visible_entries_as_strings(&panel, 5..8, cx),
 901            &[
 902                //
 903                "    > C",
 904                "      .dockerignore",
 905                "v root2",
 906            ]
 907        );
 908    }
 909
 910    #[gpui::test(iterations = 30)]
 911    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
 912        cx.foreground().forbid_parking();
 913
 914        let fs = FakeFs::new(cx.background());
 915        fs.insert_tree(
 916            "/root1",
 917            json!({
 918                ".dockerignore": "",
 919                ".git": {
 920                    "HEAD": "",
 921                },
 922                "a": {
 923                    "0": { "q": "", "r": "", "s": "" },
 924                    "1": { "t": "", "u": "" },
 925                    "2": { "v": "", "w": "", "x": "", "y": "" },
 926                },
 927                "b": {
 928                    "3": { "Q": "" },
 929                    "4": { "R": "", "S": "", "T": "", "U": "" },
 930                },
 931                "C": {
 932                    "5": {},
 933                    "6": { "V": "", "W": "" },
 934                    "7": { "X": "" },
 935                    "8": { "Y": {}, "Z": "" }
 936                }
 937            }),
 938        )
 939        .await;
 940        fs.insert_tree(
 941            "/root2",
 942            json!({
 943                "d": {
 944                    "9": ""
 945                },
 946                "e": {}
 947            }),
 948        )
 949        .await;
 950
 951        let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
 952        let params = cx.update(WorkspaceParams::test);
 953        let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
 954        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
 955
 956        select_path(&panel, "root1", cx);
 957        assert_eq!(
 958            visible_entries_as_strings(&panel, 0..10, cx),
 959            &[
 960                "v root1  <== selected",
 961                "    > a",
 962                "    > b",
 963                "    > C",
 964                "      .dockerignore",
 965                "v root2",
 966                "    > d",
 967                "    > e",
 968            ]
 969        );
 970
 971        // Add a file with the root folder selected. The filename editor is placed
 972        // before the first file in the root folder.
 973        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
 974        assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
 975        assert_eq!(
 976            visible_entries_as_strings(&panel, 0..10, cx),
 977            &[
 978                "v root1  <== selected",
 979                "    > a",
 980                "    > b",
 981                "    > C",
 982                "      [EDITOR: '']",
 983                "      .dockerignore",
 984                "v root2",
 985                "    > d",
 986                "    > e",
 987            ]
 988        );
 989
 990        panel
 991            .update(cx, |panel, cx| {
 992                panel
 993                    .filename_editor
 994                    .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
 995                panel.confirm(&Confirm, cx).unwrap()
 996            })
 997            .await
 998            .unwrap();
 999        assert_eq!(
1000            visible_entries_as_strings(&panel, 0..10, cx),
1001            &[
1002                "v root1",
1003                "    > a",
1004                "    > b",
1005                "    > C",
1006                "      .dockerignore",
1007                "      the-new-filename  <== selected",
1008                "v root2",
1009                "    > d",
1010                "    > e",
1011            ]
1012        );
1013
1014        select_path(&panel, "root1/b", cx);
1015        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1016        assert_eq!(
1017            visible_entries_as_strings(&panel, 0..9, cx),
1018            &[
1019                "v root1",
1020                "    > a",
1021                "    v b  <== selected",
1022                "        > 3",
1023                "        > 4",
1024                "          [EDITOR: '']",
1025                "    > C",
1026                "      .dockerignore",
1027                "      the-new-filename",
1028            ]
1029        );
1030
1031        panel
1032            .update(cx, |panel, cx| {
1033                panel
1034                    .filename_editor
1035                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1036                panel.confirm(&Confirm, cx).unwrap()
1037            })
1038            .await
1039            .unwrap();
1040        assert_eq!(
1041            visible_entries_as_strings(&panel, 0..9, cx),
1042            &[
1043                "v root1",
1044                "    > a",
1045                "    v b",
1046                "        > 3",
1047                "        > 4",
1048                "          another-filename  <== selected",
1049                "    > C",
1050                "      .dockerignore",
1051                "      the-new-filename",
1052            ]
1053        );
1054
1055        select_path(&panel, "root1/b/another-filename", cx);
1056        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1057        assert_eq!(
1058            visible_entries_as_strings(&panel, 0..9, cx),
1059            &[
1060                "v root1",
1061                "    > a",
1062                "    v b",
1063                "        > 3",
1064                "        > 4",
1065                "          [EDITOR: 'another-filename']  <== selected",
1066                "    > C",
1067                "      .dockerignore",
1068                "      the-new-filename",
1069            ]
1070        );
1071
1072        panel
1073            .update(cx, |panel, cx| {
1074                panel
1075                    .filename_editor
1076                    .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1077                panel.confirm(&Confirm, cx).unwrap()
1078            })
1079            .await
1080            .unwrap();
1081        assert_eq!(
1082            visible_entries_as_strings(&panel, 0..9, cx),
1083            &[
1084                "v root1",
1085                "    > a",
1086                "    v b",
1087                "        > 3",
1088                "        > 4",
1089                "          a-different-filename  <== selected",
1090                "    > C",
1091                "      .dockerignore",
1092                "      the-new-filename",
1093            ]
1094        );
1095    }
1096
1097    fn toggle_expand_dir(
1098        panel: &ViewHandle<ProjectPanel>,
1099        path: impl AsRef<Path>,
1100        cx: &mut TestAppContext,
1101    ) {
1102        let path = path.as_ref();
1103        panel.update(cx, |panel, cx| {
1104            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1105                let worktree = worktree.read(cx);
1106                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1107                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1108                    panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1109                    return;
1110                }
1111            }
1112            panic!("no worktree for path {:?}", path);
1113        });
1114    }
1115
1116    fn select_path(
1117        panel: &ViewHandle<ProjectPanel>,
1118        path: impl AsRef<Path>,
1119        cx: &mut TestAppContext,
1120    ) {
1121        let path = path.as_ref();
1122        panel.update(cx, |panel, cx| {
1123            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1124                let worktree = worktree.read(cx);
1125                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1126                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1127                    panel.selection = Some(Selection {
1128                        worktree_id: worktree.id(),
1129                        entry_id,
1130                    });
1131                    return;
1132                }
1133            }
1134            panic!("no worktree for path {:?}", path);
1135        });
1136    }
1137
1138    fn visible_entries_as_strings(
1139        panel: &ViewHandle<ProjectPanel>,
1140        range: Range<usize>,
1141        cx: &mut TestAppContext,
1142    ) -> Vec<String> {
1143        let mut result = Vec::new();
1144        let mut project_entries = HashSet::new();
1145        let mut has_editor = false;
1146        panel.update(cx, |panel, cx| {
1147            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1148                if details.is_editing {
1149                    assert!(!has_editor, "duplicate editor entry");
1150                    has_editor = true;
1151                } else {
1152                    assert!(
1153                        project_entries.insert(project_entry),
1154                        "duplicate project entry {:?} {:?}",
1155                        project_entry,
1156                        details
1157                    );
1158                }
1159
1160                let indent = "    ".repeat(details.depth);
1161                let icon = if details.kind == EntryKind::Dir {
1162                    if details.is_expanded {
1163                        "v "
1164                    } else {
1165                        "> "
1166                    }
1167                } else {
1168                    "  "
1169                };
1170                let editor_text = format!("[EDITOR: '{}']", details.filename);
1171                let name = if details.is_editing {
1172                    &editor_text
1173                } else {
1174                    &details.filename
1175                };
1176                let selected = if details.is_selected {
1177                    "  <== selected"
1178                } else {
1179                    ""
1180                };
1181                result.push(format!("{indent}{icon}{name}{selected}"));
1182            });
1183        });
1184
1185        result
1186    }
1187}