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(¶ms, 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(¶ms, 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}