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