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