1pub mod file_associations;
2mod project_panel_settings;
3use client::{ErrorCode, ErrorExt};
4use settings::Settings;
5
6use db::kvp::KEY_VALUE_STORE;
7use editor::{actions::Cancel, scroll::Autoscroll, Editor};
8use file_associations::FileAssociations;
9
10use anyhow::{anyhow, Result};
11use gpui::{
12 actions, div, overlay, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
13 ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
14 KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
15 Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext,
16 VisualContext as _, WeakView, WindowContext,
17};
18use menu::{Confirm, SelectNext, SelectPrev};
19use project::{
20 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
21 Worktree, WorktreeId,
22};
23use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
24use serde::{Deserialize, Serialize};
25use std::{
26 cmp::Ordering,
27 collections::{hash_map, HashMap},
28 ffi::OsStr,
29 ops::Range,
30 path::Path,
31 sync::Arc,
32};
33use theme::ThemeSettings;
34use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
35use unicase::UniCase;
36use util::{maybe, ResultExt, TryFutureExt};
37use workspace::{
38 dock::{DockPosition, Panel, PanelEvent},
39 notifications::DetachAndPromptErr,
40 Workspace,
41};
42
43const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
44const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
45
46pub struct ProjectPanel {
47 project: Model<Project>,
48 fs: Arc<dyn Fs>,
49 list: UniformListScrollHandle,
50 focus_handle: FocusHandle,
51 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
52 last_worktree_root_id: Option<ProjectEntryId>,
53 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
54 selection: Option<Selection>,
55 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
56 edit_state: Option<EditState>,
57 filename_editor: View<Editor>,
58 clipboard_entry: Option<ClipboardEntry>,
59 _dragged_entry_destination: Option<Arc<Path>>,
60 workspace: WeakView<Workspace>,
61 width: Option<Pixels>,
62 pending_serialization: Task<Option<()>>,
63}
64
65#[derive(Copy, Clone, Debug)]
66struct Selection {
67 worktree_id: WorktreeId,
68 entry_id: ProjectEntryId,
69}
70
71#[derive(Clone, Debug)]
72struct EditState {
73 worktree_id: WorktreeId,
74 entry_id: ProjectEntryId,
75 is_new_entry: bool,
76 is_dir: bool,
77 processing_filename: Option<String>,
78}
79
80#[derive(Copy, Clone)]
81pub enum ClipboardEntry {
82 Copied {
83 worktree_id: WorktreeId,
84 entry_id: ProjectEntryId,
85 },
86 Cut {
87 worktree_id: WorktreeId,
88 entry_id: ProjectEntryId,
89 },
90}
91
92#[derive(Debug, PartialEq, Eq, Clone)]
93pub struct EntryDetails {
94 filename: String,
95 icon: Option<Arc<str>>,
96 path: Arc<Path>,
97 depth: usize,
98 kind: EntryKind,
99 is_ignored: bool,
100 is_expanded: bool,
101 is_selected: bool,
102 is_editing: bool,
103 is_processing: bool,
104 is_cut: bool,
105 git_status: Option<GitFileStatus>,
106 is_dotenv: bool,
107}
108
109actions!(
110 project_panel,
111 [
112 ExpandSelectedEntry,
113 CollapseSelectedEntry,
114 CollapseAllEntries,
115 NewDirectory,
116 NewFile,
117 Copy,
118 CopyPath,
119 CopyRelativePath,
120 RevealInFinder,
121 OpenInTerminal,
122 Cut,
123 Paste,
124 Delete,
125 Rename,
126 Open,
127 ToggleFocus,
128 NewSearchInDirectory,
129 ]
130);
131
132pub fn init_settings(cx: &mut AppContext) {
133 ProjectPanelSettings::register(cx);
134}
135
136pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
137 init_settings(cx);
138 file_associations::init(assets, cx);
139
140 cx.observe_new_views(|workspace: &mut Workspace, _| {
141 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
142 workspace.toggle_panel_focus::<ProjectPanel>(cx);
143 });
144 })
145 .detach();
146}
147
148#[derive(Debug)]
149pub enum Event {
150 OpenedEntry {
151 entry_id: ProjectEntryId,
152 focus_opened_item: bool,
153 },
154 SplitEntry {
155 entry_id: ProjectEntryId,
156 },
157 Focus,
158}
159
160#[derive(Serialize, Deserialize)]
161struct SerializedProjectPanel {
162 width: Option<Pixels>,
163}
164
165struct DraggedProjectEntryView {
166 entry_id: ProjectEntryId,
167 details: EntryDetails,
168 width: Pixels,
169}
170
171impl ProjectPanel {
172 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
173 let project = workspace.project().clone();
174 let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
175 cx.observe(&project, |this, _, cx| {
176 this.update_visible_entries(None, cx);
177 cx.notify();
178 })
179 .detach();
180 let focus_handle = cx.focus_handle();
181
182 cx.on_focus(&focus_handle, Self::focus_in).detach();
183
184 cx.subscribe(&project, |this, project, event, cx| match event {
185 project::Event::ActiveEntryChanged(Some(entry_id)) => {
186 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
187 this.reveal_entry(project, *entry_id, true, cx);
188 }
189 }
190 project::Event::RevealInProjectPanel(entry_id) => {
191 this.reveal_entry(project, *entry_id, false, cx);
192 cx.emit(PanelEvent::Activate);
193 }
194 project::Event::ActivateProjectPanel => {
195 cx.emit(PanelEvent::Activate);
196 }
197 project::Event::WorktreeRemoved(id) => {
198 this.expanded_dir_ids.remove(id);
199 this.update_visible_entries(None, cx);
200 cx.notify();
201 }
202 _ => {}
203 })
204 .detach();
205
206 let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
207
208 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
209 editor::EditorEvent::BufferEdited
210 | editor::EditorEvent::SelectionsChanged { .. } => {
211 this.autoscroll(cx);
212 }
213 editor::EditorEvent::Blurred => {
214 if this
215 .edit_state
216 .as_ref()
217 .map_or(false, |state| state.processing_filename.is_none())
218 {
219 this.edit_state = None;
220 this.update_visible_entries(None, cx);
221 }
222 }
223 _ => {}
224 })
225 .detach();
226
227 cx.observe_global::<FileAssociations>(|_, cx| {
228 cx.notify();
229 })
230 .detach();
231
232 let mut this = Self {
233 project: project.clone(),
234 fs: workspace.app_state().fs.clone(),
235 list: UniformListScrollHandle::new(),
236 focus_handle,
237 visible_entries: Default::default(),
238 last_worktree_root_id: Default::default(),
239 expanded_dir_ids: Default::default(),
240 selection: None,
241 edit_state: None,
242 context_menu: None,
243 filename_editor,
244 clipboard_entry: None,
245 _dragged_entry_destination: None,
246 workspace: workspace.weak_handle(),
247 width: None,
248 pending_serialization: Task::ready(None),
249 };
250 this.update_visible_entries(None, cx);
251
252 this
253 });
254
255 cx.subscribe(&project_panel, {
256 let project_panel = project_panel.downgrade();
257 move |workspace, _, event, cx| match event {
258 &Event::OpenedEntry {
259 entry_id,
260 focus_opened_item,
261 } => {
262 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
263 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
264 let file_path = entry.path.clone();
265 workspace
266 .open_path(
267 ProjectPath {
268 worktree_id: worktree.read(cx).id(),
269 path: entry.path.clone(),
270 },
271 None,
272 focus_opened_item,
273 cx,
274 )
275 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
276 match e.error_code() {
277 ErrorCode::UnsharedItem => Some(format!(
278 "{} is not shared by the host. This could be because it has been marked as `private`",
279 file_path.display()
280 )),
281 _ => None,
282 }
283 });
284 if !focus_opened_item {
285 if let Some(project_panel) = project_panel.upgrade() {
286 let focus_handle = project_panel.read(cx).focus_handle.clone();
287 cx.focus(&focus_handle);
288 }
289 }
290 }
291 }
292 }
293 &Event::SplitEntry { entry_id } => {
294 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
295 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
296 workspace
297 .split_path(
298 ProjectPath {
299 worktree_id: worktree.read(cx).id(),
300 path: entry.path.clone(),
301 },
302 cx,
303 )
304 .detach_and_log_err(cx);
305 }
306 }
307 }
308 _ => {}
309 }
310 })
311 .detach();
312
313 project_panel
314 }
315
316 pub async fn load(
317 workspace: WeakView<Workspace>,
318 mut cx: AsyncWindowContext,
319 ) -> Result<View<Self>> {
320 let serialized_panel = cx
321 .background_executor()
322 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
323 .await
324 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
325 .log_err()
326 .flatten()
327 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
328 .transpose()
329 .log_err()
330 .flatten();
331
332 workspace.update(&mut cx, |workspace, cx| {
333 let panel = ProjectPanel::new(workspace, cx);
334 if let Some(serialized_panel) = serialized_panel {
335 panel.update(cx, |panel, cx| {
336 panel.width = serialized_panel.width;
337 cx.notify();
338 });
339 }
340 panel
341 })
342 }
343
344 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
345 let width = self.width;
346 self.pending_serialization = cx.background_executor().spawn(
347 async move {
348 KEY_VALUE_STORE
349 .write_kvp(
350 PROJECT_PANEL_KEY.into(),
351 serde_json::to_string(&SerializedProjectPanel { width })?,
352 )
353 .await?;
354 anyhow::Ok(())
355 }
356 .log_err(),
357 );
358 }
359
360 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
361 if !self.focus_handle.contains_focused(cx) {
362 cx.emit(Event::Focus);
363 }
364 }
365
366 fn deploy_context_menu(
367 &mut self,
368 position: Point<Pixels>,
369 entry_id: ProjectEntryId,
370 cx: &mut ViewContext<Self>,
371 ) {
372 let this = cx.view().clone();
373 let project = self.project.read(cx);
374
375 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
376 id
377 } else {
378 return;
379 };
380
381 self.selection = Some(Selection {
382 worktree_id,
383 entry_id,
384 });
385
386 if let Some((worktree, entry)) = self.selected_entry(cx) {
387 let is_root = Some(entry) == worktree.root_entry();
388 let is_dir = entry.is_dir();
389 let worktree_id = worktree.id();
390 let is_local = project.is_local();
391 let is_read_only = project.is_read_only();
392
393 let context_menu = ContextMenu::build(cx, |menu, cx| {
394 menu.context(self.focus_handle.clone()).when_else(
395 is_read_only,
396 |menu| {
397 menu.action("Copy Relative Path", Box::new(CopyRelativePath))
398 .when(is_dir, |menu| {
399 menu.action("Search Inside", Box::new(NewSearchInDirectory))
400 })
401 },
402 |menu| {
403 menu.when(is_local, |menu| {
404 menu.action(
405 "Add Folder to Project",
406 Box::new(workspace::AddFolderToProject),
407 )
408 .when(is_root, |menu| {
409 menu.entry(
410 "Remove from Project",
411 None,
412 cx.handler_for(&this, move |this, cx| {
413 this.project.update(cx, |project, cx| {
414 project.remove_worktree(worktree_id, cx)
415 });
416 }),
417 )
418 })
419 })
420 .action("New File", Box::new(NewFile))
421 .action("New Folder", Box::new(NewDirectory))
422 .separator()
423 .action("Cut", Box::new(Cut))
424 .action("Copy", Box::new(Copy))
425 .when_some(self.clipboard_entry, |menu, entry| {
426 menu.when(entry.worktree_id() == worktree_id, |menu| {
427 menu.action("Paste", Box::new(Paste))
428 })
429 })
430 .separator()
431 .action("Copy Path", Box::new(CopyPath))
432 .action("Copy Relative Path", Box::new(CopyRelativePath))
433 .separator()
434 .action("Reveal in Finder", Box::new(RevealInFinder))
435 .when(is_dir, |menu| {
436 menu.action("Open in Terminal", Box::new(OpenInTerminal))
437 .action("Search Inside", Box::new(NewSearchInDirectory))
438 })
439 .separator()
440 .action("Rename", Box::new(Rename))
441 .when(!is_root, |menu| menu.action("Delete", Box::new(Delete)))
442 },
443 )
444 });
445
446 cx.focus_view(&context_menu);
447 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
448 this.context_menu.take();
449 cx.notify();
450 });
451 self.context_menu = Some((context_menu, position, subscription));
452 }
453
454 cx.notify();
455 }
456
457 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
458 if let Some((worktree, entry)) = self.selected_entry(cx) {
459 if entry.is_dir() {
460 let worktree_id = worktree.id();
461 let entry_id = entry.id;
462 let expanded_dir_ids =
463 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
464 expanded_dir_ids
465 } else {
466 return;
467 };
468
469 match expanded_dir_ids.binary_search(&entry_id) {
470 Ok(_) => self.select_next(&SelectNext, cx),
471 Err(ix) => {
472 self.project.update(cx, |project, cx| {
473 project.expand_entry(worktree_id, entry_id, cx);
474 });
475
476 expanded_dir_ids.insert(ix, entry_id);
477 self.update_visible_entries(None, cx);
478 cx.notify();
479 }
480 }
481 }
482 }
483 }
484
485 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
486 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
487 let worktree_id = worktree.id();
488 let expanded_dir_ids =
489 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
490 expanded_dir_ids
491 } else {
492 return;
493 };
494
495 loop {
496 let entry_id = entry.id;
497 match expanded_dir_ids.binary_search(&entry_id) {
498 Ok(ix) => {
499 expanded_dir_ids.remove(ix);
500 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
501 cx.notify();
502 break;
503 }
504 Err(_) => {
505 if let Some(parent_entry) =
506 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
507 {
508 entry = parent_entry;
509 } else {
510 break;
511 }
512 }
513 }
514 }
515 }
516 }
517
518 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
519 self.expanded_dir_ids.clear();
520 self.update_visible_entries(None, cx);
521 cx.notify();
522 }
523
524 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
525 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
526 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
527 self.project.update(cx, |project, cx| {
528 match expanded_dir_ids.binary_search(&entry_id) {
529 Ok(ix) => {
530 expanded_dir_ids.remove(ix);
531 }
532 Err(ix) => {
533 project.expand_entry(worktree_id, entry_id, cx);
534 expanded_dir_ids.insert(ix, entry_id);
535 }
536 }
537 });
538 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
539 cx.focus(&self.focus_handle);
540 cx.notify();
541 }
542 }
543 }
544
545 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
546 if let Some(selection) = self.selection {
547 let (mut worktree_ix, mut entry_ix, _) =
548 self.index_for_selection(selection).unwrap_or_default();
549 if entry_ix > 0 {
550 entry_ix -= 1;
551 } else if worktree_ix > 0 {
552 worktree_ix -= 1;
553 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
554 } else {
555 return;
556 }
557
558 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
559 self.selection = Some(Selection {
560 worktree_id: *worktree_id,
561 entry_id: worktree_entries[entry_ix].id,
562 });
563 self.autoscroll(cx);
564 cx.notify();
565 } else {
566 self.select_first(cx);
567 }
568 }
569
570 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
571 if let Some(task) = self.confirm_edit(cx) {
572 task.detach_and_log_err(cx);
573 }
574 }
575
576 fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
577 if let Some((_, entry)) = self.selected_entry(cx) {
578 if entry.is_file() {
579 self.open_entry(entry.id, true, cx);
580 } else {
581 self.toggle_expanded(entry.id, cx);
582 }
583 }
584 }
585
586 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
587 let edit_state = self.edit_state.as_mut()?;
588 cx.focus(&self.focus_handle);
589
590 let worktree_id = edit_state.worktree_id;
591 let is_new_entry = edit_state.is_new_entry;
592 let is_dir = edit_state.is_dir;
593 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
594 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
595 let filename = self.filename_editor.read(cx).text(cx);
596
597 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
598 let edit_task;
599 let edited_entry_id;
600 if is_new_entry {
601 self.selection = Some(Selection {
602 worktree_id,
603 entry_id: NEW_ENTRY_ID,
604 });
605 let new_path = entry.path.join(&filename.trim_start_matches("/"));
606 if path_already_exists(new_path.as_path()) {
607 return None;
608 }
609
610 edited_entry_id = NEW_ENTRY_ID;
611 edit_task = self.project.update(cx, |project, cx| {
612 project.create_entry((worktree_id, &new_path), is_dir, cx)
613 });
614 } else {
615 let new_path = if let Some(parent) = entry.path.clone().parent() {
616 parent.join(&filename)
617 } else {
618 filename.clone().into()
619 };
620 if path_already_exists(new_path.as_path()) {
621 return None;
622 }
623
624 edited_entry_id = entry.id;
625 edit_task = self.project.update(cx, |project, cx| {
626 project.rename_entry(entry.id, new_path.as_path(), cx)
627 });
628 };
629
630 edit_state.processing_filename = Some(filename);
631 cx.notify();
632
633 Some(cx.spawn(|this, mut cx| async move {
634 let new_entry = edit_task.await;
635 this.update(&mut cx, |this, cx| {
636 this.edit_state.take();
637 cx.notify();
638 })?;
639
640 if let Some(new_entry) = new_entry? {
641 this.update(&mut cx, |this, cx| {
642 if let Some(selection) = &mut this.selection {
643 if selection.entry_id == edited_entry_id {
644 selection.worktree_id = worktree_id;
645 selection.entry_id = new_entry.id;
646 this.expand_to_selection(cx);
647 }
648 }
649 this.update_visible_entries(None, cx);
650 if is_new_entry && !is_dir {
651 this.open_entry(new_entry.id, true, cx);
652 }
653 cx.notify();
654 })?;
655 }
656 Ok(())
657 }))
658 }
659
660 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
661 self.edit_state = None;
662 self.update_visible_entries(None, cx);
663 cx.focus(&self.focus_handle);
664 cx.notify();
665 }
666
667 fn open_entry(
668 &mut self,
669 entry_id: ProjectEntryId,
670 focus_opened_item: bool,
671 cx: &mut ViewContext<Self>,
672 ) {
673 cx.emit(Event::OpenedEntry {
674 entry_id,
675 focus_opened_item,
676 });
677 }
678
679 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
680 cx.emit(Event::SplitEntry { entry_id });
681 }
682
683 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
684 self.add_entry(false, cx)
685 }
686
687 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
688 self.add_entry(true, cx)
689 }
690
691 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
692 if let Some(Selection {
693 worktree_id,
694 entry_id,
695 }) = self.selection
696 {
697 let directory_id;
698 if let Some((worktree, expanded_dir_ids)) = self
699 .project
700 .read(cx)
701 .worktree_for_id(worktree_id, cx)
702 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
703 {
704 let worktree = worktree.read(cx);
705 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
706 loop {
707 if entry.is_dir() {
708 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
709 expanded_dir_ids.insert(ix, entry.id);
710 }
711 directory_id = entry.id;
712 break;
713 } else {
714 if let Some(parent_path) = entry.path.parent() {
715 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
716 entry = parent_entry;
717 continue;
718 }
719 }
720 return;
721 }
722 }
723 } else {
724 return;
725 };
726 } else {
727 return;
728 };
729
730 self.edit_state = Some(EditState {
731 worktree_id,
732 entry_id: directory_id,
733 is_new_entry: true,
734 is_dir,
735 processing_filename: None,
736 });
737 self.filename_editor.update(cx, |editor, cx| {
738 editor.clear(cx);
739 editor.focus(cx);
740 });
741 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
742 self.autoscroll(cx);
743 cx.notify();
744 }
745 }
746
747 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
748 if let Some(Selection {
749 worktree_id,
750 entry_id,
751 }) = self.selection
752 {
753 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
754 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
755 self.edit_state = Some(EditState {
756 worktree_id,
757 entry_id,
758 is_new_entry: false,
759 is_dir: entry.is_dir(),
760 processing_filename: None,
761 });
762 let file_name = entry
763 .path
764 .file_name()
765 .map(|s| s.to_string_lossy())
766 .unwrap_or_default()
767 .to_string();
768 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
769 let selection_end =
770 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
771 self.filename_editor.update(cx, |editor, cx| {
772 editor.set_text(file_name, cx);
773 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
774 s.select_ranges([0..selection_end])
775 });
776 editor.focus(cx);
777 });
778 self.update_visible_entries(None, cx);
779 self.autoscroll(cx);
780 cx.notify();
781 }
782 }
783 }
784 }
785
786 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
787 maybe!({
788 let Selection { entry_id, .. } = self.selection?;
789 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
790 let file_name = path.file_name()?;
791
792 let answer = cx.prompt(
793 PromptLevel::Info,
794 &format!("Delete {file_name:?}?"),
795 None,
796 &["Delete", "Cancel"],
797 );
798
799 cx.spawn(|this, mut cx| async move {
800 if answer.await != Ok(0) {
801 return Ok(());
802 }
803 this.update(&mut cx, |this, cx| {
804 this.project
805 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
806 .ok_or_else(|| anyhow!("no such entry"))
807 })??
808 .await
809 })
810 .detach_and_log_err(cx);
811 Some(())
812 });
813 }
814
815 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
816 if let Some(selection) = self.selection {
817 let (mut worktree_ix, mut entry_ix, _) =
818 self.index_for_selection(selection).unwrap_or_default();
819 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
820 if entry_ix + 1 < worktree_entries.len() {
821 entry_ix += 1;
822 } else {
823 worktree_ix += 1;
824 entry_ix = 0;
825 }
826 }
827
828 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
829 if let Some(entry) = worktree_entries.get(entry_ix) {
830 self.selection = Some(Selection {
831 worktree_id: *worktree_id,
832 entry_id: entry.id,
833 });
834 self.autoscroll(cx);
835 cx.notify();
836 }
837 }
838 } else {
839 self.select_first(cx);
840 }
841 }
842
843 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
844 let worktree = self
845 .visible_entries
846 .first()
847 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
848 if let Some(worktree) = worktree {
849 let worktree = worktree.read(cx);
850 let worktree_id = worktree.id();
851 if let Some(root_entry) = worktree.root_entry() {
852 self.selection = Some(Selection {
853 worktree_id,
854 entry_id: root_entry.id,
855 });
856 self.autoscroll(cx);
857 cx.notify();
858 }
859 }
860 }
861
862 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
863 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
864 self.list.scroll_to_item(index);
865 cx.notify();
866 }
867 }
868
869 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
870 if let Some((worktree, entry)) = self.selected_entry(cx) {
871 self.clipboard_entry = Some(ClipboardEntry::Cut {
872 worktree_id: worktree.id(),
873 entry_id: entry.id,
874 });
875 cx.notify();
876 }
877 }
878
879 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
880 if let Some((worktree, entry)) = self.selected_entry(cx) {
881 self.clipboard_entry = Some(ClipboardEntry::Copied {
882 worktree_id: worktree.id(),
883 entry_id: entry.id,
884 });
885 cx.notify();
886 }
887 }
888
889 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
890 maybe!({
891 let (worktree, entry) = self.selected_entry(cx)?;
892 let clipboard_entry = self.clipboard_entry?;
893 if clipboard_entry.worktree_id() != worktree.id() {
894 return None;
895 }
896
897 let clipboard_entry_file_name = self
898 .project
899 .read(cx)
900 .path_for_entry(clipboard_entry.entry_id(), cx)?
901 .path
902 .file_name()?
903 .to_os_string();
904
905 let mut new_path = entry.path.to_path_buf();
906 if entry.is_file() {
907 new_path.pop();
908 }
909
910 new_path.push(&clipboard_entry_file_name);
911 let extension = new_path.extension().map(|e| e.to_os_string());
912 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
913 let mut ix = 0;
914 while worktree.entry_for_path(&new_path).is_some() {
915 new_path.pop();
916
917 let mut new_file_name = file_name_without_extension.to_os_string();
918 new_file_name.push(" copy");
919 if ix > 0 {
920 new_file_name.push(format!(" {}", ix));
921 }
922 if let Some(extension) = extension.as_ref() {
923 new_file_name.push(".");
924 new_file_name.push(extension);
925 }
926
927 new_path.push(new_file_name);
928 ix += 1;
929 }
930
931 if clipboard_entry.is_cut() {
932 self.project
933 .update(cx, |project, cx| {
934 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
935 })
936 .detach_and_log_err(cx)
937 } else {
938 self.project
939 .update(cx, |project, cx| {
940 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
941 })
942 .detach_and_log_err(cx)
943 }
944
945 Some(())
946 });
947 }
948
949 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
950 if let Some((worktree, entry)) = self.selected_entry(cx) {
951 cx.write_to_clipboard(ClipboardItem::new(
952 worktree
953 .abs_path()
954 .join(&entry.path)
955 .to_string_lossy()
956 .to_string(),
957 ));
958 }
959 }
960
961 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
962 if let Some((_, entry)) = self.selected_entry(cx) {
963 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
964 }
965 }
966
967 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
968 if let Some((worktree, entry)) = self.selected_entry(cx) {
969 cx.reveal_path(&worktree.abs_path().join(&entry.path));
970 }
971 }
972
973 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
974 if let Some((worktree, entry)) = self.selected_entry(cx) {
975 let path = worktree.abs_path().join(&entry.path);
976 cx.dispatch_action(
977 workspace::OpenTerminal {
978 working_directory: path,
979 }
980 .boxed_clone(),
981 )
982 }
983 }
984
985 pub fn new_search_in_directory(
986 &mut self,
987 _: &NewSearchInDirectory,
988 cx: &mut ViewContext<Self>,
989 ) {
990 if let Some((_, entry)) = self.selected_entry(cx) {
991 if entry.is_dir() {
992 let entry = entry.clone();
993 self.workspace
994 .update(cx, |workspace, cx| {
995 search::ProjectSearchView::new_search_in_directory(workspace, &entry, cx);
996 })
997 .ok();
998 }
999 }
1000 }
1001
1002 fn move_entry(
1003 &mut self,
1004 entry_to_move: ProjectEntryId,
1005 destination: ProjectEntryId,
1006 destination_is_file: bool,
1007 cx: &mut ViewContext<Self>,
1008 ) {
1009 let destination_worktree = self.project.update(cx, |project, cx| {
1010 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1011 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1012
1013 let mut destination_path = destination_entry_path.as_ref();
1014 if destination_is_file {
1015 destination_path = destination_path.parent()?;
1016 }
1017
1018 let mut new_path = destination_path.to_path_buf();
1019 new_path.push(entry_path.path.file_name()?);
1020 if new_path != entry_path.path.as_ref() {
1021 let task = project.rename_entry(entry_to_move, new_path, cx);
1022 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1023 }
1024
1025 Some(project.worktree_id_for_entry(destination, cx)?)
1026 });
1027
1028 if let Some(destination_worktree) = destination_worktree {
1029 self.expand_entry(destination_worktree, destination, cx);
1030 }
1031 }
1032
1033 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1034 let mut entry_index = 0;
1035 let mut visible_entries_index = 0;
1036 for (worktree_index, (worktree_id, worktree_entries)) in
1037 self.visible_entries.iter().enumerate()
1038 {
1039 if *worktree_id == selection.worktree_id {
1040 for entry in worktree_entries {
1041 if entry.id == selection.entry_id {
1042 return Some((worktree_index, entry_index, visible_entries_index));
1043 } else {
1044 visible_entries_index += 1;
1045 entry_index += 1;
1046 }
1047 }
1048 break;
1049 } else {
1050 visible_entries_index += worktree_entries.len();
1051 }
1052 }
1053 None
1054 }
1055
1056 pub fn selected_entry<'a>(
1057 &self,
1058 cx: &'a AppContext,
1059 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1060 let (worktree, entry) = self.selected_entry_handle(cx)?;
1061 Some((worktree.read(cx), entry))
1062 }
1063
1064 fn selected_entry_handle<'a>(
1065 &self,
1066 cx: &'a AppContext,
1067 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1068 let selection = self.selection?;
1069 let project = self.project.read(cx);
1070 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1071 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1072 Some((worktree, entry))
1073 }
1074
1075 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1076 let (worktree, entry) = self.selected_entry(cx)?;
1077 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1078
1079 for path in entry.path.ancestors() {
1080 let Some(entry) = worktree.entry_for_path(path) else {
1081 continue;
1082 };
1083 if entry.is_dir() {
1084 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1085 expanded_dir_ids.insert(idx, entry.id);
1086 }
1087 }
1088 }
1089
1090 Some(())
1091 }
1092
1093 fn update_visible_entries(
1094 &mut self,
1095 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1096 cx: &mut ViewContext<Self>,
1097 ) {
1098 let project = self.project.read(cx);
1099 self.last_worktree_root_id = project
1100 .visible_worktrees(cx)
1101 .rev()
1102 .next()
1103 .and_then(|worktree| worktree.read(cx).root_entry())
1104 .map(|entry| entry.id);
1105
1106 self.visible_entries.clear();
1107 for worktree in project.visible_worktrees(cx) {
1108 let snapshot = worktree.read(cx).snapshot();
1109 let worktree_id = snapshot.id();
1110
1111 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1112 hash_map::Entry::Occupied(e) => e.into_mut(),
1113 hash_map::Entry::Vacant(e) => {
1114 // The first time a worktree's root entry becomes available,
1115 // mark that root entry as expanded.
1116 if let Some(entry) = snapshot.root_entry() {
1117 e.insert(vec![entry.id]).as_slice()
1118 } else {
1119 &[]
1120 }
1121 }
1122 };
1123
1124 let mut new_entry_parent_id = None;
1125 let mut new_entry_kind = EntryKind::Dir;
1126 if let Some(edit_state) = &self.edit_state {
1127 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1128 new_entry_parent_id = Some(edit_state.entry_id);
1129 new_entry_kind = if edit_state.is_dir {
1130 EntryKind::Dir
1131 } else {
1132 EntryKind::File(Default::default())
1133 };
1134 }
1135 }
1136
1137 let mut visible_worktree_entries = Vec::new();
1138 let mut entry_iter = snapshot.entries(true);
1139
1140 while let Some(entry) = entry_iter.entry() {
1141 visible_worktree_entries.push(entry.clone());
1142 if Some(entry.id) == new_entry_parent_id {
1143 visible_worktree_entries.push(Entry {
1144 id: NEW_ENTRY_ID,
1145 kind: new_entry_kind,
1146 path: entry.path.join("\0").into(),
1147 inode: 0,
1148 mtime: entry.mtime,
1149 is_symlink: false,
1150 is_ignored: false,
1151 is_external: false,
1152 is_private: false,
1153 git_status: entry.git_status,
1154 });
1155 }
1156 if expanded_dir_ids.binary_search(&entry.id).is_err()
1157 && entry_iter.advance_to_sibling()
1158 {
1159 continue;
1160 }
1161 entry_iter.advance();
1162 }
1163
1164 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1165
1166 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1167 let mut components_a = entry_a.path.components().peekable();
1168 let mut components_b = entry_b.path.components().peekable();
1169 loop {
1170 match (components_a.next(), components_b.next()) {
1171 (Some(component_a), Some(component_b)) => {
1172 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1173 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1174 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1175 let name_a =
1176 UniCase::new(component_a.as_os_str().to_string_lossy());
1177 let name_b =
1178 UniCase::new(component_b.as_os_str().to_string_lossy());
1179 name_a.cmp(&name_b)
1180 });
1181 if !ordering.is_eq() {
1182 return ordering;
1183 }
1184 }
1185 (Some(_), None) => break Ordering::Greater,
1186 (None, Some(_)) => break Ordering::Less,
1187 (None, None) => break Ordering::Equal,
1188 }
1189 }
1190 });
1191 self.visible_entries
1192 .push((worktree_id, visible_worktree_entries));
1193 }
1194
1195 if let Some((worktree_id, entry_id)) = new_selected_entry {
1196 self.selection = Some(Selection {
1197 worktree_id,
1198 entry_id,
1199 });
1200 }
1201 }
1202
1203 fn expand_entry(
1204 &mut self,
1205 worktree_id: WorktreeId,
1206 entry_id: ProjectEntryId,
1207 cx: &mut ViewContext<Self>,
1208 ) {
1209 self.project.update(cx, |project, cx| {
1210 if let Some((worktree, expanded_dir_ids)) = project
1211 .worktree_for_id(worktree_id, cx)
1212 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1213 {
1214 project.expand_entry(worktree_id, entry_id, cx);
1215 let worktree = worktree.read(cx);
1216
1217 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1218 loop {
1219 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1220 expanded_dir_ids.insert(ix, entry.id);
1221 }
1222
1223 if let Some(parent_entry) =
1224 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1225 {
1226 entry = parent_entry;
1227 } else {
1228 break;
1229 }
1230 }
1231 }
1232 }
1233 });
1234 }
1235
1236 fn for_each_visible_entry(
1237 &self,
1238 range: Range<usize>,
1239 cx: &mut ViewContext<ProjectPanel>,
1240 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1241 ) {
1242 let mut ix = 0;
1243 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1244 if ix >= range.end {
1245 return;
1246 }
1247
1248 if ix + visible_worktree_entries.len() <= range.start {
1249 ix += visible_worktree_entries.len();
1250 continue;
1251 }
1252
1253 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1254 let (git_status_setting, show_file_icons, show_folder_icons) = {
1255 let settings = ProjectPanelSettings::get_global(cx);
1256 (
1257 settings.git_status,
1258 settings.file_icons,
1259 settings.folder_icons,
1260 )
1261 };
1262 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1263 let snapshot = worktree.read(cx).snapshot();
1264 let root_name = OsStr::new(snapshot.root_name());
1265 let expanded_entry_ids = self
1266 .expanded_dir_ids
1267 .get(&snapshot.id())
1268 .map(Vec::as_slice)
1269 .unwrap_or(&[]);
1270
1271 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1272 for entry in visible_worktree_entries[entry_range].iter() {
1273 let status = git_status_setting.then(|| entry.git_status).flatten();
1274 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1275 let icon = match entry.kind {
1276 EntryKind::File(_) => {
1277 if show_file_icons {
1278 FileAssociations::get_icon(&entry.path, cx)
1279 } else {
1280 None
1281 }
1282 }
1283 _ => {
1284 if show_folder_icons {
1285 FileAssociations::get_folder_icon(is_expanded, cx)
1286 } else {
1287 FileAssociations::get_chevron_icon(is_expanded, cx)
1288 }
1289 }
1290 };
1291
1292 let mut details = EntryDetails {
1293 filename: entry
1294 .path
1295 .file_name()
1296 .unwrap_or(root_name)
1297 .to_string_lossy()
1298 .to_string(),
1299 icon,
1300 path: entry.path.clone(),
1301 depth: entry.path.components().count(),
1302 kind: entry.kind,
1303 is_ignored: entry.is_ignored,
1304 is_expanded,
1305 is_selected: self.selection.map_or(false, |e| {
1306 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1307 }),
1308 is_editing: false,
1309 is_processing: false,
1310 is_cut: self
1311 .clipboard_entry
1312 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1313 git_status: status,
1314 is_dotenv: entry.is_private,
1315 };
1316
1317 if let Some(edit_state) = &self.edit_state {
1318 let is_edited_entry = if edit_state.is_new_entry {
1319 entry.id == NEW_ENTRY_ID
1320 } else {
1321 entry.id == edit_state.entry_id
1322 };
1323
1324 if is_edited_entry {
1325 if let Some(processing_filename) = &edit_state.processing_filename {
1326 details.is_processing = true;
1327 details.filename.clear();
1328 details.filename.push_str(processing_filename);
1329 } else {
1330 if edit_state.is_new_entry {
1331 details.filename.clear();
1332 }
1333 details.is_editing = true;
1334 }
1335 }
1336 }
1337
1338 callback(entry.id, details, cx);
1339 }
1340 }
1341 ix = end_ix;
1342 }
1343 }
1344
1345 fn render_entry(
1346 &self,
1347 entry_id: ProjectEntryId,
1348 details: EntryDetails,
1349 cx: &mut ViewContext<Self>,
1350 ) -> Stateful<Div> {
1351 let kind = details.kind;
1352 let settings = ProjectPanelSettings::get_global(cx);
1353 let show_editor = details.is_editing && !details.is_processing;
1354 let is_selected = self
1355 .selection
1356 .map_or(false, |selection| selection.entry_id == entry_id);
1357 let width = self.width.unwrap_or(px(0.));
1358
1359 let filename_text_color = details
1360 .git_status
1361 .as_ref()
1362 .map(|status| match status {
1363 GitFileStatus::Added => Color::Created,
1364 GitFileStatus::Modified => Color::Modified,
1365 GitFileStatus::Conflict => Color::Conflict,
1366 })
1367 .unwrap_or(if is_selected {
1368 Color::Default
1369 } else if details.is_ignored {
1370 Color::Disabled
1371 } else {
1372 Color::Muted
1373 });
1374
1375 let file_name = details.filename.clone();
1376 let icon = details.icon.clone();
1377 let depth = details.depth;
1378 div()
1379 .id(entry_id.to_proto() as usize)
1380 .on_drag(entry_id, move |entry_id, cx| {
1381 cx.new_view(|_| DraggedProjectEntryView {
1382 details: details.clone(),
1383 width,
1384 entry_id: *entry_id,
1385 })
1386 })
1387 .drag_over::<ProjectEntryId>(|style, _, cx| {
1388 style.bg(cx.theme().colors().drop_target_background)
1389 })
1390 .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1391 this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1392 }))
1393 .child(
1394 ListItem::new(entry_id.to_proto() as usize)
1395 .indent_level(depth)
1396 .indent_step_size(px(settings.indent_size))
1397 .selected(is_selected)
1398 .child(if let Some(icon) = &icon {
1399 div().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1400 } else {
1401 div().size(IconSize::default().rems()).invisible()
1402 })
1403 .child(
1404 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1405 div().h_full().w_full().child(editor.clone())
1406 } else {
1407 div().child(Label::new(file_name).color(filename_text_color))
1408 }
1409 .ml_1(),
1410 )
1411 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1412 if event.down.button == MouseButton::Right {
1413 return;
1414 }
1415 if !show_editor {
1416 if kind.is_dir() {
1417 this.toggle_expanded(entry_id, cx);
1418 } else {
1419 if event.down.modifiers.command {
1420 this.split_entry(entry_id, cx);
1421 } else {
1422 this.open_entry(entry_id, event.up.click_count > 1, cx);
1423 }
1424 }
1425 }
1426 }))
1427 .on_secondary_mouse_down(cx.listener(
1428 move |this, event: &MouseDownEvent, cx| {
1429 // Stop propagation to prevent the catch-all context menu for the project
1430 // panel from being deployed.
1431 cx.stop_propagation();
1432 this.deploy_context_menu(event.position, entry_id, cx);
1433 },
1434 )),
1435 )
1436 }
1437
1438 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1439 let mut dispatch_context = KeyContext::default();
1440 dispatch_context.add("ProjectPanel");
1441 dispatch_context.add("menu");
1442
1443 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1444 "editing"
1445 } else {
1446 "not_editing"
1447 };
1448
1449 dispatch_context.add(identifier);
1450 dispatch_context
1451 }
1452
1453 fn reveal_entry(
1454 &mut self,
1455 project: Model<Project>,
1456 entry_id: ProjectEntryId,
1457 skip_ignored: bool,
1458 cx: &mut ViewContext<'_, ProjectPanel>,
1459 ) {
1460 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1461 let worktree = worktree.read(cx);
1462 if skip_ignored
1463 && worktree
1464 .entry_for_id(entry_id)
1465 .map_or(true, |entry| entry.is_ignored)
1466 {
1467 return;
1468 }
1469
1470 let worktree_id = worktree.id();
1471 self.expand_entry(worktree_id, entry_id, cx);
1472 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1473 self.autoscroll(cx);
1474 cx.notify();
1475 }
1476 }
1477}
1478
1479impl Render for ProjectPanel {
1480 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1481 let has_worktree = self.visible_entries.len() != 0;
1482 let project = self.project.read(cx);
1483
1484 if has_worktree {
1485 div()
1486 .id("project-panel")
1487 .size_full()
1488 .relative()
1489 .key_context(self.dispatch_context(cx))
1490 .on_action(cx.listener(Self::select_next))
1491 .on_action(cx.listener(Self::select_prev))
1492 .on_action(cx.listener(Self::expand_selected_entry))
1493 .on_action(cx.listener(Self::collapse_selected_entry))
1494 .on_action(cx.listener(Self::collapse_all_entries))
1495 .on_action(cx.listener(Self::open))
1496 .on_action(cx.listener(Self::confirm))
1497 .on_action(cx.listener(Self::cancel))
1498 .on_action(cx.listener(Self::copy_path))
1499 .on_action(cx.listener(Self::copy_relative_path))
1500 .on_action(cx.listener(Self::new_search_in_directory))
1501 .when(!project.is_read_only(), |el| {
1502 el.on_action(cx.listener(Self::new_file))
1503 .on_action(cx.listener(Self::new_directory))
1504 .on_action(cx.listener(Self::rename))
1505 .on_action(cx.listener(Self::delete))
1506 .on_action(cx.listener(Self::cut))
1507 .on_action(cx.listener(Self::copy))
1508 .on_action(cx.listener(Self::paste))
1509 })
1510 .when(project.is_local(), |el| {
1511 el.on_action(cx.listener(Self::reveal_in_finder))
1512 .on_action(cx.listener(Self::open_in_terminal))
1513 })
1514 .on_mouse_down(
1515 MouseButton::Right,
1516 cx.listener(move |this, event: &MouseDownEvent, cx| {
1517 // When deploying the context menu anywhere below the last project entry,
1518 // act as if the user clicked the root of the last worktree.
1519 if let Some(entry_id) = this.last_worktree_root_id {
1520 this.deploy_context_menu(event.position, entry_id, cx);
1521 }
1522 }),
1523 )
1524 .track_focus(&self.focus_handle)
1525 .child(
1526 uniform_list(
1527 cx.view().clone(),
1528 "entries",
1529 self.visible_entries
1530 .iter()
1531 .map(|(_, worktree_entries)| worktree_entries.len())
1532 .sum(),
1533 {
1534 |this, range, cx| {
1535 let mut items = Vec::new();
1536 this.for_each_visible_entry(range, cx, |id, details, cx| {
1537 items.push(this.render_entry(id, details, cx));
1538 });
1539 items
1540 }
1541 },
1542 )
1543 .size_full()
1544 .track_scroll(self.list.clone()),
1545 )
1546 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1547 overlay()
1548 .position(*position)
1549 .anchor(gpui::AnchorCorner::TopLeft)
1550 .child(menu.clone())
1551 }))
1552 } else {
1553 v_flex()
1554 .id("empty-project_panel")
1555 .size_full()
1556 .p_4()
1557 .track_focus(&self.focus_handle)
1558 .child(
1559 Button::new("open_project", "Open a project")
1560 .style(ButtonStyle::Filled)
1561 .full_width()
1562 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1563 .on_click(cx.listener(|this, _, cx| {
1564 this.workspace
1565 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1566 .log_err();
1567 })),
1568 )
1569 }
1570 }
1571}
1572
1573impl Render for DraggedProjectEntryView {
1574 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1575 let settings = ProjectPanelSettings::get_global(cx);
1576 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1577 h_flex()
1578 .font(ui_font)
1579 .bg(cx.theme().colors().background)
1580 .w(self.width)
1581 .child(
1582 ListItem::new(self.entry_id.to_proto() as usize)
1583 .indent_level(self.details.depth)
1584 .indent_step_size(px(settings.indent_size))
1585 .child(if let Some(icon) = &self.details.icon {
1586 div().child(Icon::from_path(icon.to_string()))
1587 } else {
1588 div()
1589 })
1590 .child(Label::new(self.details.filename.clone())),
1591 )
1592 }
1593}
1594
1595impl EventEmitter<Event> for ProjectPanel {}
1596
1597impl EventEmitter<PanelEvent> for ProjectPanel {}
1598
1599impl Panel for ProjectPanel {
1600 fn position(&self, cx: &WindowContext) -> DockPosition {
1601 match ProjectPanelSettings::get_global(cx).dock {
1602 ProjectPanelDockPosition::Left => DockPosition::Left,
1603 ProjectPanelDockPosition::Right => DockPosition::Right,
1604 }
1605 }
1606
1607 fn position_is_valid(&self, position: DockPosition) -> bool {
1608 matches!(position, DockPosition::Left | DockPosition::Right)
1609 }
1610
1611 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1612 settings::update_settings_file::<ProjectPanelSettings>(
1613 self.fs.clone(),
1614 cx,
1615 move |settings| {
1616 let dock = match position {
1617 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1618 DockPosition::Right => ProjectPanelDockPosition::Right,
1619 };
1620 settings.dock = Some(dock);
1621 },
1622 );
1623 }
1624
1625 fn size(&self, cx: &WindowContext) -> Pixels {
1626 self.width
1627 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1628 }
1629
1630 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1631 self.width = size;
1632 self.serialize(cx);
1633 cx.notify();
1634 }
1635
1636 fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1637 Some(ui::IconName::FileTree)
1638 }
1639
1640 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1641 Some("Project Panel")
1642 }
1643
1644 fn toggle_action(&self) -> Box<dyn Action> {
1645 Box::new(ToggleFocus)
1646 }
1647
1648 fn persistent_name() -> &'static str {
1649 "Project Panel"
1650 }
1651
1652 fn starts_open(&self, cx: &WindowContext) -> bool {
1653 self.project.read(cx).visible_worktrees(cx).any(|tree| {
1654 tree.read(cx)
1655 .root_entry()
1656 .map_or(false, |entry| entry.is_dir())
1657 })
1658 }
1659}
1660
1661impl FocusableView for ProjectPanel {
1662 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1663 self.focus_handle.clone()
1664 }
1665}
1666
1667impl ClipboardEntry {
1668 fn is_cut(&self) -> bool {
1669 matches!(self, Self::Cut { .. })
1670 }
1671
1672 fn entry_id(&self) -> ProjectEntryId {
1673 match self {
1674 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1675 *entry_id
1676 }
1677 }
1678 }
1679
1680 fn worktree_id(&self) -> WorktreeId {
1681 match self {
1682 ClipboardEntry::Copied { worktree_id, .. }
1683 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1684 }
1685 }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690 use super::*;
1691 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1692 use pretty_assertions::assert_eq;
1693 use project::{project_settings::ProjectSettings, FakeFs};
1694 use serde_json::json;
1695 use settings::SettingsStore;
1696 use std::{
1697 collections::HashSet,
1698 path::{Path, PathBuf},
1699 };
1700 use workspace::AppState;
1701
1702 #[gpui::test]
1703 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1704 init_test(cx);
1705
1706 let fs = FakeFs::new(cx.executor().clone());
1707 fs.insert_tree(
1708 "/root1",
1709 json!({
1710 ".dockerignore": "",
1711 ".git": {
1712 "HEAD": "",
1713 },
1714 "a": {
1715 "0": { "q": "", "r": "", "s": "" },
1716 "1": { "t": "", "u": "" },
1717 "2": { "v": "", "w": "", "x": "", "y": "" },
1718 },
1719 "b": {
1720 "3": { "Q": "" },
1721 "4": { "R": "", "S": "", "T": "", "U": "" },
1722 },
1723 "C": {
1724 "5": {},
1725 "6": { "V": "", "W": "" },
1726 "7": { "X": "" },
1727 "8": { "Y": {}, "Z": "" }
1728 }
1729 }),
1730 )
1731 .await;
1732 fs.insert_tree(
1733 "/root2",
1734 json!({
1735 "d": {
1736 "9": ""
1737 },
1738 "e": {}
1739 }),
1740 )
1741 .await;
1742
1743 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1744 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1745 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1746 let panel = workspace
1747 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1748 .unwrap();
1749 assert_eq!(
1750 visible_entries_as_strings(&panel, 0..50, cx),
1751 &[
1752 "v root1",
1753 " > .git",
1754 " > a",
1755 " > b",
1756 " > C",
1757 " .dockerignore",
1758 "v root2",
1759 " > d",
1760 " > e",
1761 ]
1762 );
1763
1764 toggle_expand_dir(&panel, "root1/b", cx);
1765 assert_eq!(
1766 visible_entries_as_strings(&panel, 0..50, cx),
1767 &[
1768 "v root1",
1769 " > .git",
1770 " > a",
1771 " v b <== selected",
1772 " > 3",
1773 " > 4",
1774 " > C",
1775 " .dockerignore",
1776 "v root2",
1777 " > d",
1778 " > e",
1779 ]
1780 );
1781
1782 assert_eq!(
1783 visible_entries_as_strings(&panel, 6..9, cx),
1784 &[
1785 //
1786 " > C",
1787 " .dockerignore",
1788 "v root2",
1789 ]
1790 );
1791 }
1792
1793 #[gpui::test]
1794 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1795 init_test(cx);
1796 cx.update(|cx| {
1797 cx.update_global::<SettingsStore, _>(|store, cx| {
1798 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1799 project_settings.file_scan_exclusions =
1800 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1801 });
1802 });
1803 });
1804
1805 let fs = FakeFs::new(cx.background_executor.clone());
1806 fs.insert_tree(
1807 "/root1",
1808 json!({
1809 ".dockerignore": "",
1810 ".git": {
1811 "HEAD": "",
1812 },
1813 "a": {
1814 "0": { "q": "", "r": "", "s": "" },
1815 "1": { "t": "", "u": "" },
1816 "2": { "v": "", "w": "", "x": "", "y": "" },
1817 },
1818 "b": {
1819 "3": { "Q": "" },
1820 "4": { "R": "", "S": "", "T": "", "U": "" },
1821 },
1822 "C": {
1823 "5": {},
1824 "6": { "V": "", "W": "" },
1825 "7": { "X": "" },
1826 "8": { "Y": {}, "Z": "" }
1827 }
1828 }),
1829 )
1830 .await;
1831 fs.insert_tree(
1832 "/root2",
1833 json!({
1834 "d": {
1835 "4": ""
1836 },
1837 "e": {}
1838 }),
1839 )
1840 .await;
1841
1842 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1843 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1844 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1845 let panel = workspace
1846 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1847 .unwrap();
1848 assert_eq!(
1849 visible_entries_as_strings(&panel, 0..50, cx),
1850 &[
1851 "v root1",
1852 " > a",
1853 " > b",
1854 " > C",
1855 " .dockerignore",
1856 "v root2",
1857 " > d",
1858 " > e",
1859 ]
1860 );
1861
1862 toggle_expand_dir(&panel, "root1/b", cx);
1863 assert_eq!(
1864 visible_entries_as_strings(&panel, 0..50, cx),
1865 &[
1866 "v root1",
1867 " > a",
1868 " v b <== selected",
1869 " > 3",
1870 " > C",
1871 " .dockerignore",
1872 "v root2",
1873 " > d",
1874 " > e",
1875 ]
1876 );
1877
1878 toggle_expand_dir(&panel, "root2/d", cx);
1879 assert_eq!(
1880 visible_entries_as_strings(&panel, 0..50, cx),
1881 &[
1882 "v root1",
1883 " > a",
1884 " v b",
1885 " > 3",
1886 " > C",
1887 " .dockerignore",
1888 "v root2",
1889 " v d <== selected",
1890 " > e",
1891 ]
1892 );
1893
1894 toggle_expand_dir(&panel, "root2/e", cx);
1895 assert_eq!(
1896 visible_entries_as_strings(&panel, 0..50, cx),
1897 &[
1898 "v root1",
1899 " > a",
1900 " v b",
1901 " > 3",
1902 " > C",
1903 " .dockerignore",
1904 "v root2",
1905 " v d",
1906 " v e <== selected",
1907 ]
1908 );
1909 }
1910
1911 #[gpui::test(iterations = 30)]
1912 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1913 init_test(cx);
1914
1915 let fs = FakeFs::new(cx.executor().clone());
1916 fs.insert_tree(
1917 "/root1",
1918 json!({
1919 ".dockerignore": "",
1920 ".git": {
1921 "HEAD": "",
1922 },
1923 "a": {
1924 "0": { "q": "", "r": "", "s": "" },
1925 "1": { "t": "", "u": "" },
1926 "2": { "v": "", "w": "", "x": "", "y": "" },
1927 },
1928 "b": {
1929 "3": { "Q": "" },
1930 "4": { "R": "", "S": "", "T": "", "U": "" },
1931 },
1932 "C": {
1933 "5": {},
1934 "6": { "V": "", "W": "" },
1935 "7": { "X": "" },
1936 "8": { "Y": {}, "Z": "" }
1937 }
1938 }),
1939 )
1940 .await;
1941 fs.insert_tree(
1942 "/root2",
1943 json!({
1944 "d": {
1945 "9": ""
1946 },
1947 "e": {}
1948 }),
1949 )
1950 .await;
1951
1952 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1953 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1954 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1955 let panel = workspace
1956 .update(cx, |workspace, cx| {
1957 let panel = ProjectPanel::new(workspace, cx);
1958 workspace.add_panel(panel.clone(), cx);
1959 panel
1960 })
1961 .unwrap();
1962
1963 select_path(&panel, "root1", cx);
1964 assert_eq!(
1965 visible_entries_as_strings(&panel, 0..10, cx),
1966 &[
1967 "v root1 <== selected",
1968 " > .git",
1969 " > a",
1970 " > b",
1971 " > C",
1972 " .dockerignore",
1973 "v root2",
1974 " > d",
1975 " > e",
1976 ]
1977 );
1978
1979 // Add a file with the root folder selected. The filename editor is placed
1980 // before the first file in the root folder.
1981 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1982 panel.update(cx, |panel, cx| {
1983 assert!(panel.filename_editor.read(cx).is_focused(cx));
1984 });
1985 assert_eq!(
1986 visible_entries_as_strings(&panel, 0..10, cx),
1987 &[
1988 "v root1",
1989 " > .git",
1990 " > a",
1991 " > b",
1992 " > C",
1993 " [EDITOR: ''] <== selected",
1994 " .dockerignore",
1995 "v root2",
1996 " > d",
1997 " > e",
1998 ]
1999 );
2000
2001 let confirm = panel.update(cx, |panel, cx| {
2002 panel
2003 .filename_editor
2004 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2005 panel.confirm_edit(cx).unwrap()
2006 });
2007 assert_eq!(
2008 visible_entries_as_strings(&panel, 0..10, cx),
2009 &[
2010 "v root1",
2011 " > .git",
2012 " > a",
2013 " > b",
2014 " > C",
2015 " [PROCESSING: 'the-new-filename'] <== selected",
2016 " .dockerignore",
2017 "v root2",
2018 " > d",
2019 " > e",
2020 ]
2021 );
2022
2023 confirm.await.unwrap();
2024 assert_eq!(
2025 visible_entries_as_strings(&panel, 0..10, cx),
2026 &[
2027 "v root1",
2028 " > .git",
2029 " > a",
2030 " > b",
2031 " > C",
2032 " .dockerignore",
2033 " the-new-filename <== selected",
2034 "v root2",
2035 " > d",
2036 " > e",
2037 ]
2038 );
2039
2040 select_path(&panel, "root1/b", cx);
2041 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2042 assert_eq!(
2043 visible_entries_as_strings(&panel, 0..10, cx),
2044 &[
2045 "v root1",
2046 " > .git",
2047 " > a",
2048 " v b",
2049 " > 3",
2050 " > 4",
2051 " [EDITOR: ''] <== selected",
2052 " > C",
2053 " .dockerignore",
2054 " the-new-filename",
2055 ]
2056 );
2057
2058 panel
2059 .update(cx, |panel, cx| {
2060 panel
2061 .filename_editor
2062 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2063 panel.confirm_edit(cx).unwrap()
2064 })
2065 .await
2066 .unwrap();
2067 assert_eq!(
2068 visible_entries_as_strings(&panel, 0..10, cx),
2069 &[
2070 "v root1",
2071 " > .git",
2072 " > a",
2073 " v b",
2074 " > 3",
2075 " > 4",
2076 " another-filename.txt <== selected",
2077 " > C",
2078 " .dockerignore",
2079 " the-new-filename",
2080 ]
2081 );
2082
2083 select_path(&panel, "root1/b/another-filename.txt", cx);
2084 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2085 assert_eq!(
2086 visible_entries_as_strings(&panel, 0..10, cx),
2087 &[
2088 "v root1",
2089 " > .git",
2090 " > a",
2091 " v b",
2092 " > 3",
2093 " > 4",
2094 " [EDITOR: 'another-filename.txt'] <== selected",
2095 " > C",
2096 " .dockerignore",
2097 " the-new-filename",
2098 ]
2099 );
2100
2101 let confirm = panel.update(cx, |panel, cx| {
2102 panel.filename_editor.update(cx, |editor, cx| {
2103 let file_name_selections = editor.selections.all::<usize>(cx);
2104 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2105 let file_name_selection = &file_name_selections[0];
2106 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2107 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2108
2109 editor.set_text("a-different-filename.tar.gz", cx)
2110 });
2111 panel.confirm_edit(cx).unwrap()
2112 });
2113 assert_eq!(
2114 visible_entries_as_strings(&panel, 0..10, cx),
2115 &[
2116 "v root1",
2117 " > .git",
2118 " > a",
2119 " v b",
2120 " > 3",
2121 " > 4",
2122 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2123 " > C",
2124 " .dockerignore",
2125 " the-new-filename",
2126 ]
2127 );
2128
2129 confirm.await.unwrap();
2130 assert_eq!(
2131 visible_entries_as_strings(&panel, 0..10, cx),
2132 &[
2133 "v root1",
2134 " > .git",
2135 " > a",
2136 " v b",
2137 " > 3",
2138 " > 4",
2139 " a-different-filename.tar.gz <== selected",
2140 " > C",
2141 " .dockerignore",
2142 " the-new-filename",
2143 ]
2144 );
2145
2146 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2147 assert_eq!(
2148 visible_entries_as_strings(&panel, 0..10, cx),
2149 &[
2150 "v root1",
2151 " > .git",
2152 " > a",
2153 " v b",
2154 " > 3",
2155 " > 4",
2156 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2157 " > C",
2158 " .dockerignore",
2159 " the-new-filename",
2160 ]
2161 );
2162
2163 panel.update(cx, |panel, cx| {
2164 panel.filename_editor.update(cx, |editor, cx| {
2165 let file_name_selections = editor.selections.all::<usize>(cx);
2166 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2167 let file_name_selection = &file_name_selections[0];
2168 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2169 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
2170
2171 });
2172 panel.cancel(&Cancel, cx)
2173 });
2174
2175 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2176 assert_eq!(
2177 visible_entries_as_strings(&panel, 0..10, cx),
2178 &[
2179 "v root1",
2180 " > .git",
2181 " > a",
2182 " v b",
2183 " > [EDITOR: ''] <== selected",
2184 " > 3",
2185 " > 4",
2186 " a-different-filename.tar.gz",
2187 " > C",
2188 " .dockerignore",
2189 ]
2190 );
2191
2192 let confirm = panel.update(cx, |panel, cx| {
2193 panel
2194 .filename_editor
2195 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2196 panel.confirm_edit(cx).unwrap()
2197 });
2198 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2199 assert_eq!(
2200 visible_entries_as_strings(&panel, 0..10, cx),
2201 &[
2202 "v root1",
2203 " > .git",
2204 " > a",
2205 " v b",
2206 " > [PROCESSING: 'new-dir']",
2207 " > 3 <== selected",
2208 " > 4",
2209 " a-different-filename.tar.gz",
2210 " > C",
2211 " .dockerignore",
2212 ]
2213 );
2214
2215 confirm.await.unwrap();
2216 assert_eq!(
2217 visible_entries_as_strings(&panel, 0..10, cx),
2218 &[
2219 "v root1",
2220 " > .git",
2221 " > a",
2222 " v b",
2223 " > 3 <== selected",
2224 " > 4",
2225 " > new-dir",
2226 " a-different-filename.tar.gz",
2227 " > C",
2228 " .dockerignore",
2229 ]
2230 );
2231
2232 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2233 assert_eq!(
2234 visible_entries_as_strings(&panel, 0..10, cx),
2235 &[
2236 "v root1",
2237 " > .git",
2238 " > a",
2239 " v b",
2240 " > [EDITOR: '3'] <== selected",
2241 " > 4",
2242 " > new-dir",
2243 " a-different-filename.tar.gz",
2244 " > C",
2245 " .dockerignore",
2246 ]
2247 );
2248
2249 // Dismiss the rename editor when it loses focus.
2250 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2251 assert_eq!(
2252 visible_entries_as_strings(&panel, 0..10, cx),
2253 &[
2254 "v root1",
2255 " > .git",
2256 " > a",
2257 " v b",
2258 " > 3 <== selected",
2259 " > 4",
2260 " > new-dir",
2261 " a-different-filename.tar.gz",
2262 " > C",
2263 " .dockerignore",
2264 ]
2265 );
2266 }
2267
2268 #[gpui::test(iterations = 10)]
2269 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2270 init_test(cx);
2271
2272 let fs = FakeFs::new(cx.executor().clone());
2273 fs.insert_tree(
2274 "/root1",
2275 json!({
2276 ".dockerignore": "",
2277 ".git": {
2278 "HEAD": "",
2279 },
2280 "a": {
2281 "0": { "q": "", "r": "", "s": "" },
2282 "1": { "t": "", "u": "" },
2283 "2": { "v": "", "w": "", "x": "", "y": "" },
2284 },
2285 "b": {
2286 "3": { "Q": "" },
2287 "4": { "R": "", "S": "", "T": "", "U": "" },
2288 },
2289 "C": {
2290 "5": {},
2291 "6": { "V": "", "W": "" },
2292 "7": { "X": "" },
2293 "8": { "Y": {}, "Z": "" }
2294 }
2295 }),
2296 )
2297 .await;
2298 fs.insert_tree(
2299 "/root2",
2300 json!({
2301 "d": {
2302 "9": ""
2303 },
2304 "e": {}
2305 }),
2306 )
2307 .await;
2308
2309 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2310 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2311 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2312 let panel = workspace
2313 .update(cx, |workspace, cx| {
2314 let panel = ProjectPanel::new(workspace, cx);
2315 workspace.add_panel(panel.clone(), cx);
2316 panel
2317 })
2318 .unwrap();
2319
2320 select_path(&panel, "root1", cx);
2321 assert_eq!(
2322 visible_entries_as_strings(&panel, 0..10, cx),
2323 &[
2324 "v root1 <== selected",
2325 " > .git",
2326 " > a",
2327 " > b",
2328 " > C",
2329 " .dockerignore",
2330 "v root2",
2331 " > d",
2332 " > e",
2333 ]
2334 );
2335
2336 // Add a file with the root folder selected. The filename editor is placed
2337 // before the first file in the root folder.
2338 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2339 panel.update(cx, |panel, cx| {
2340 assert!(panel.filename_editor.read(cx).is_focused(cx));
2341 });
2342 assert_eq!(
2343 visible_entries_as_strings(&panel, 0..10, cx),
2344 &[
2345 "v root1",
2346 " > .git",
2347 " > a",
2348 " > b",
2349 " > C",
2350 " [EDITOR: ''] <== selected",
2351 " .dockerignore",
2352 "v root2",
2353 " > d",
2354 " > e",
2355 ]
2356 );
2357
2358 let confirm = panel.update(cx, |panel, cx| {
2359 panel.filename_editor.update(cx, |editor, cx| {
2360 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2361 });
2362 panel.confirm_edit(cx).unwrap()
2363 });
2364
2365 assert_eq!(
2366 visible_entries_as_strings(&panel, 0..10, cx),
2367 &[
2368 "v root1",
2369 " > .git",
2370 " > a",
2371 " > b",
2372 " > C",
2373 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2374 " .dockerignore",
2375 "v root2",
2376 " > d",
2377 " > e",
2378 ]
2379 );
2380
2381 confirm.await.unwrap();
2382 assert_eq!(
2383 visible_entries_as_strings(&panel, 0..13, cx),
2384 &[
2385 "v root1",
2386 " > .git",
2387 " > a",
2388 " > b",
2389 " v bdir1",
2390 " v dir2",
2391 " the-new-filename <== selected",
2392 " > C",
2393 " .dockerignore",
2394 "v root2",
2395 " > d",
2396 " > e",
2397 ]
2398 );
2399 }
2400
2401 #[gpui::test]
2402 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2403 init_test(cx);
2404
2405 let fs = FakeFs::new(cx.executor().clone());
2406 fs.insert_tree(
2407 "/root1",
2408 json!({
2409 "one.two.txt": "",
2410 "one.txt": ""
2411 }),
2412 )
2413 .await;
2414
2415 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2416 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2417 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2418 let panel = workspace
2419 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2420 .unwrap();
2421
2422 panel.update(cx, |panel, cx| {
2423 panel.select_next(&Default::default(), cx);
2424 panel.select_next(&Default::default(), cx);
2425 });
2426
2427 assert_eq!(
2428 visible_entries_as_strings(&panel, 0..50, cx),
2429 &[
2430 //
2431 "v root1",
2432 " one.two.txt <== selected",
2433 " one.txt",
2434 ]
2435 );
2436
2437 // Regression test - file name is created correctly when
2438 // the copied file's name contains multiple dots.
2439 panel.update(cx, |panel, cx| {
2440 panel.copy(&Default::default(), cx);
2441 panel.paste(&Default::default(), cx);
2442 });
2443 cx.executor().run_until_parked();
2444
2445 assert_eq!(
2446 visible_entries_as_strings(&panel, 0..50, cx),
2447 &[
2448 //
2449 "v root1",
2450 " one.two copy.txt",
2451 " one.two.txt <== selected",
2452 " one.txt",
2453 ]
2454 );
2455
2456 panel.update(cx, |panel, cx| {
2457 panel.paste(&Default::default(), cx);
2458 });
2459 cx.executor().run_until_parked();
2460
2461 assert_eq!(
2462 visible_entries_as_strings(&panel, 0..50, cx),
2463 &[
2464 //
2465 "v root1",
2466 " one.two copy 1.txt",
2467 " one.two copy.txt",
2468 " one.two.txt <== selected",
2469 " one.txt",
2470 ]
2471 );
2472 }
2473
2474 #[gpui::test]
2475 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2476 init_test(cx);
2477
2478 let fs = FakeFs::new(cx.executor().clone());
2479 fs.insert_tree(
2480 "/root",
2481 json!({
2482 "a": {
2483 "one.txt": "",
2484 "two.txt": "",
2485 "inner_dir": {
2486 "three.txt": "",
2487 "four.txt": "",
2488 }
2489 },
2490 "b": {}
2491 }),
2492 )
2493 .await;
2494
2495 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2496 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2497 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2498 let panel = workspace
2499 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2500 .unwrap();
2501
2502 select_path(&panel, "root/a", cx);
2503 panel.update(cx, |panel, cx| {
2504 panel.copy(&Default::default(), cx);
2505 panel.select_next(&Default::default(), cx);
2506 panel.paste(&Default::default(), cx);
2507 });
2508 cx.executor().run_until_parked();
2509
2510 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2511 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2512
2513 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2514 assert_ne!(
2515 pasted_dir_file, None,
2516 "Pasted directory file should have an entry"
2517 );
2518
2519 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2520 assert_ne!(
2521 pasted_dir_inner_dir, None,
2522 "Directories inside pasted directory should have an entry"
2523 );
2524
2525 toggle_expand_dir(&panel, "root/b", cx);
2526 toggle_expand_dir(&panel, "root/b/a", cx);
2527 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2528
2529 assert_eq!(
2530 visible_entries_as_strings(&panel, 0..50, cx),
2531 &[
2532 //
2533 "v root",
2534 " > a",
2535 " v b",
2536 " v a",
2537 " v inner_dir <== selected",
2538 " four.txt",
2539 " three.txt",
2540 " one.txt",
2541 " two.txt",
2542 ]
2543 );
2544
2545 select_path(&panel, "root", cx);
2546 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2547 cx.executor().run_until_parked();
2548 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2549 cx.executor().run_until_parked();
2550 assert_eq!(
2551 visible_entries_as_strings(&panel, 0..50, cx),
2552 &[
2553 //
2554 "v root <== selected",
2555 " > a",
2556 " > a copy",
2557 " > a copy 1",
2558 " v b",
2559 " v a",
2560 " v inner_dir",
2561 " four.txt",
2562 " three.txt",
2563 " one.txt",
2564 " two.txt"
2565 ]
2566 );
2567 }
2568
2569 #[gpui::test]
2570 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2571 init_test_with_editor(cx);
2572
2573 let fs = FakeFs::new(cx.executor().clone());
2574 fs.insert_tree(
2575 "/src",
2576 json!({
2577 "test": {
2578 "first.rs": "// First Rust file",
2579 "second.rs": "// Second Rust file",
2580 "third.rs": "// Third Rust file",
2581 }
2582 }),
2583 )
2584 .await;
2585
2586 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2587 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2588 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2589 let panel = workspace
2590 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2591 .unwrap();
2592
2593 toggle_expand_dir(&panel, "src/test", cx);
2594 select_path(&panel, "src/test/first.rs", cx);
2595 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2596 cx.executor().run_until_parked();
2597 assert_eq!(
2598 visible_entries_as_strings(&panel, 0..10, cx),
2599 &[
2600 "v src",
2601 " v test",
2602 " first.rs <== selected",
2603 " second.rs",
2604 " third.rs"
2605 ]
2606 );
2607 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2608
2609 submit_deletion(&panel, cx);
2610 assert_eq!(
2611 visible_entries_as_strings(&panel, 0..10, cx),
2612 &[
2613 "v src",
2614 " v test",
2615 " second.rs",
2616 " third.rs"
2617 ],
2618 "Project panel should have no deleted file, no other file is selected in it"
2619 );
2620 ensure_no_open_items_and_panes(&workspace, cx);
2621
2622 select_path(&panel, "src/test/second.rs", cx);
2623 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2624 cx.executor().run_until_parked();
2625 assert_eq!(
2626 visible_entries_as_strings(&panel, 0..10, cx),
2627 &[
2628 "v src",
2629 " v test",
2630 " second.rs <== selected",
2631 " third.rs"
2632 ]
2633 );
2634 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2635
2636 workspace
2637 .update(cx, |workspace, cx| {
2638 let active_items = workspace
2639 .panes()
2640 .iter()
2641 .filter_map(|pane| pane.read(cx).active_item())
2642 .collect::<Vec<_>>();
2643 assert_eq!(active_items.len(), 1);
2644 let open_editor = active_items
2645 .into_iter()
2646 .next()
2647 .unwrap()
2648 .downcast::<Editor>()
2649 .expect("Open item should be an editor");
2650 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2651 })
2652 .unwrap();
2653 submit_deletion(&panel, cx);
2654 assert_eq!(
2655 visible_entries_as_strings(&panel, 0..10, cx),
2656 &["v src", " v test", " third.rs"],
2657 "Project panel should have no deleted file, with one last file remaining"
2658 );
2659 ensure_no_open_items_and_panes(&workspace, cx);
2660 }
2661
2662 #[gpui::test]
2663 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2664 init_test_with_editor(cx);
2665
2666 let fs = FakeFs::new(cx.executor().clone());
2667 fs.insert_tree(
2668 "/src",
2669 json!({
2670 "test": {
2671 "first.rs": "// First Rust file",
2672 "second.rs": "// Second Rust file",
2673 "third.rs": "// Third Rust file",
2674 }
2675 }),
2676 )
2677 .await;
2678
2679 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2680 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2681 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2682 let panel = workspace
2683 .update(cx, |workspace, cx| {
2684 let panel = ProjectPanel::new(workspace, cx);
2685 workspace.add_panel(panel.clone(), cx);
2686 panel
2687 })
2688 .unwrap();
2689
2690 select_path(&panel, "src/", cx);
2691 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2692 cx.executor().run_until_parked();
2693 assert_eq!(
2694 visible_entries_as_strings(&panel, 0..10, cx),
2695 &[
2696 //
2697 "v src <== selected",
2698 " > test"
2699 ]
2700 );
2701 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2702 panel.update(cx, |panel, cx| {
2703 assert!(panel.filename_editor.read(cx).is_focused(cx));
2704 });
2705 assert_eq!(
2706 visible_entries_as_strings(&panel, 0..10, cx),
2707 &[
2708 //
2709 "v src",
2710 " > [EDITOR: ''] <== selected",
2711 " > test"
2712 ]
2713 );
2714 panel.update(cx, |panel, cx| {
2715 panel
2716 .filename_editor
2717 .update(cx, |editor, cx| editor.set_text("test", cx));
2718 assert!(
2719 panel.confirm_edit(cx).is_none(),
2720 "Should not allow to confirm on conflicting new directory name"
2721 )
2722 });
2723 assert_eq!(
2724 visible_entries_as_strings(&panel, 0..10, cx),
2725 &[
2726 //
2727 "v src",
2728 " > test"
2729 ],
2730 "File list should be unchanged after failed folder create confirmation"
2731 );
2732
2733 select_path(&panel, "src/test/", cx);
2734 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2735 cx.executor().run_until_parked();
2736 assert_eq!(
2737 visible_entries_as_strings(&panel, 0..10, cx),
2738 &[
2739 //
2740 "v src",
2741 " > test <== selected"
2742 ]
2743 );
2744 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2745 panel.update(cx, |panel, cx| {
2746 assert!(panel.filename_editor.read(cx).is_focused(cx));
2747 });
2748 assert_eq!(
2749 visible_entries_as_strings(&panel, 0..10, cx),
2750 &[
2751 "v src",
2752 " v test",
2753 " [EDITOR: ''] <== selected",
2754 " first.rs",
2755 " second.rs",
2756 " third.rs"
2757 ]
2758 );
2759 panel.update(cx, |panel, cx| {
2760 panel
2761 .filename_editor
2762 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2763 assert!(
2764 panel.confirm_edit(cx).is_none(),
2765 "Should not allow to confirm on conflicting new file name"
2766 )
2767 });
2768 assert_eq!(
2769 visible_entries_as_strings(&panel, 0..10, cx),
2770 &[
2771 "v src",
2772 " v test",
2773 " first.rs",
2774 " second.rs",
2775 " third.rs"
2776 ],
2777 "File list should be unchanged after failed file create confirmation"
2778 );
2779
2780 select_path(&panel, "src/test/first.rs", cx);
2781 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2782 cx.executor().run_until_parked();
2783 assert_eq!(
2784 visible_entries_as_strings(&panel, 0..10, cx),
2785 &[
2786 "v src",
2787 " v test",
2788 " first.rs <== selected",
2789 " second.rs",
2790 " third.rs"
2791 ],
2792 );
2793 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2794 panel.update(cx, |panel, cx| {
2795 assert!(panel.filename_editor.read(cx).is_focused(cx));
2796 });
2797 assert_eq!(
2798 visible_entries_as_strings(&panel, 0..10, cx),
2799 &[
2800 "v src",
2801 " v test",
2802 " [EDITOR: 'first.rs'] <== selected",
2803 " second.rs",
2804 " third.rs"
2805 ]
2806 );
2807 panel.update(cx, |panel, cx| {
2808 panel
2809 .filename_editor
2810 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2811 assert!(
2812 panel.confirm_edit(cx).is_none(),
2813 "Should not allow to confirm on conflicting file rename"
2814 )
2815 });
2816 assert_eq!(
2817 visible_entries_as_strings(&panel, 0..10, cx),
2818 &[
2819 "v src",
2820 " v test",
2821 " first.rs <== selected",
2822 " second.rs",
2823 " third.rs"
2824 ],
2825 "File list should be unchanged after failed rename confirmation"
2826 );
2827 }
2828
2829 #[gpui::test]
2830 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2831 init_test_with_editor(cx);
2832
2833 let fs = FakeFs::new(cx.executor().clone());
2834 fs.insert_tree(
2835 "/project_root",
2836 json!({
2837 "dir_1": {
2838 "nested_dir": {
2839 "file_a.py": "# File contents",
2840 }
2841 },
2842 "file_1.py": "# File contents",
2843 }),
2844 )
2845 .await;
2846
2847 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2848 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2849 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2850 let panel = workspace
2851 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2852 .unwrap();
2853
2854 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2855 cx.executor().run_until_parked();
2856 select_path(&panel, "project_root/dir_1", cx);
2857 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2858 select_path(&panel, "project_root/dir_1/nested_dir", cx);
2859 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2860 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2861 cx.executor().run_until_parked();
2862 assert_eq!(
2863 visible_entries_as_strings(&panel, 0..10, cx),
2864 &[
2865 "v project_root",
2866 " v dir_1",
2867 " > nested_dir <== selected",
2868 " file_1.py",
2869 ]
2870 );
2871 }
2872
2873 #[gpui::test]
2874 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2875 init_test_with_editor(cx);
2876
2877 let fs = FakeFs::new(cx.executor().clone());
2878 fs.insert_tree(
2879 "/project_root",
2880 json!({
2881 "dir_1": {
2882 "nested_dir": {
2883 "file_a.py": "# File contents",
2884 "file_b.py": "# File contents",
2885 "file_c.py": "# File contents",
2886 },
2887 "file_1.py": "# File contents",
2888 "file_2.py": "# File contents",
2889 "file_3.py": "# File contents",
2890 },
2891 "dir_2": {
2892 "file_1.py": "# File contents",
2893 "file_2.py": "# File contents",
2894 "file_3.py": "# File contents",
2895 }
2896 }),
2897 )
2898 .await;
2899
2900 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2901 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2902 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2903 let panel = workspace
2904 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2905 .unwrap();
2906
2907 panel.update(cx, |panel, cx| {
2908 panel.collapse_all_entries(&CollapseAllEntries, cx)
2909 });
2910 cx.executor().run_until_parked();
2911 assert_eq!(
2912 visible_entries_as_strings(&panel, 0..10, cx),
2913 &["v project_root", " > dir_1", " > dir_2",]
2914 );
2915
2916 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2917 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2918 cx.executor().run_until_parked();
2919 assert_eq!(
2920 visible_entries_as_strings(&panel, 0..10, cx),
2921 &[
2922 "v project_root",
2923 " v dir_1 <== selected",
2924 " > nested_dir",
2925 " file_1.py",
2926 " file_2.py",
2927 " file_3.py",
2928 " > dir_2",
2929 ]
2930 );
2931 }
2932
2933 #[gpui::test]
2934 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2935 init_test(cx);
2936
2937 let fs = FakeFs::new(cx.executor().clone());
2938 fs.as_fake().insert_tree("/root", json!({})).await;
2939 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2940 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2941 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2942 let panel = workspace
2943 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2944 .unwrap();
2945
2946 // Make a new buffer with no backing file
2947 workspace
2948 .update(cx, |workspace, cx| {
2949 Editor::new_file(workspace, &Default::default(), cx)
2950 })
2951 .unwrap();
2952
2953 // "Save as"" the buffer, creating a new backing file for it
2954 let save_task = workspace
2955 .update(cx, |workspace, cx| {
2956 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2957 })
2958 .unwrap();
2959
2960 cx.executor().run_until_parked();
2961 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2962 save_task.await.unwrap();
2963
2964 // Rename the file
2965 select_path(&panel, "root/new", cx);
2966 assert_eq!(
2967 visible_entries_as_strings(&panel, 0..10, cx),
2968 &["v root", " new <== selected"]
2969 );
2970 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2971 panel.update(cx, |panel, cx| {
2972 panel
2973 .filename_editor
2974 .update(cx, |editor, cx| editor.set_text("newer", cx));
2975 });
2976 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2977
2978 cx.executor().run_until_parked();
2979 assert_eq!(
2980 visible_entries_as_strings(&panel, 0..10, cx),
2981 &["v root", " newer <== selected"]
2982 );
2983
2984 workspace
2985 .update(cx, |workspace, cx| {
2986 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2987 })
2988 .unwrap()
2989 .await
2990 .unwrap();
2991
2992 cx.executor().run_until_parked();
2993 // assert that saving the file doesn't restore "new"
2994 assert_eq!(
2995 visible_entries_as_strings(&panel, 0..10, cx),
2996 &["v root", " newer <== selected"]
2997 );
2998 }
2999
3000 #[gpui::test]
3001 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3002 init_test_with_editor(cx);
3003 cx.update(|cx| {
3004 cx.update_global::<SettingsStore, _>(|store, cx| {
3005 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3006 project_settings.file_scan_exclusions = Some(Vec::new());
3007 });
3008 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3009 project_panel_settings.auto_reveal_entries = Some(false)
3010 });
3011 })
3012 });
3013
3014 let fs = FakeFs::new(cx.background_executor.clone());
3015 fs.insert_tree(
3016 "/project_root",
3017 json!({
3018 ".git": {},
3019 ".gitignore": "**/gitignored_dir",
3020 "dir_1": {
3021 "file_1.py": "# File 1_1 contents",
3022 "file_2.py": "# File 1_2 contents",
3023 "file_3.py": "# File 1_3 contents",
3024 "gitignored_dir": {
3025 "file_a.py": "# File contents",
3026 "file_b.py": "# File contents",
3027 "file_c.py": "# File contents",
3028 },
3029 },
3030 "dir_2": {
3031 "file_1.py": "# File 2_1 contents",
3032 "file_2.py": "# File 2_2 contents",
3033 "file_3.py": "# File 2_3 contents",
3034 }
3035 }),
3036 )
3037 .await;
3038
3039 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3040 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3041 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3042 let panel = workspace
3043 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3044 .unwrap();
3045
3046 assert_eq!(
3047 visible_entries_as_strings(&panel, 0..20, cx),
3048 &[
3049 "v project_root",
3050 " > .git",
3051 " > dir_1",
3052 " > dir_2",
3053 " .gitignore",
3054 ]
3055 );
3056
3057 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3058 .expect("dir 1 file is not ignored and should have an entry");
3059 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3060 .expect("dir 2 file is not ignored and should have an entry");
3061 let gitignored_dir_file =
3062 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3063 assert_eq!(
3064 gitignored_dir_file, None,
3065 "File in the gitignored dir should not have an entry before its dir is toggled"
3066 );
3067
3068 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3069 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3070 cx.executor().run_until_parked();
3071 assert_eq!(
3072 visible_entries_as_strings(&panel, 0..20, cx),
3073 &[
3074 "v project_root",
3075 " > .git",
3076 " v dir_1",
3077 " v gitignored_dir <== selected",
3078 " file_a.py",
3079 " file_b.py",
3080 " file_c.py",
3081 " file_1.py",
3082 " file_2.py",
3083 " file_3.py",
3084 " > dir_2",
3085 " .gitignore",
3086 ],
3087 "Should show gitignored dir file list in the project panel"
3088 );
3089 let gitignored_dir_file =
3090 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3091 .expect("after gitignored dir got opened, a file entry should be present");
3092
3093 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3094 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3095 assert_eq!(
3096 visible_entries_as_strings(&panel, 0..20, cx),
3097 &[
3098 "v project_root",
3099 " > .git",
3100 " > dir_1 <== selected",
3101 " > dir_2",
3102 " .gitignore",
3103 ],
3104 "Should hide all dir contents again and prepare for the auto reveal test"
3105 );
3106
3107 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3108 panel.update(cx, |panel, cx| {
3109 panel.project.update(cx, |_, cx| {
3110 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3111 })
3112 });
3113 cx.run_until_parked();
3114 assert_eq!(
3115 visible_entries_as_strings(&panel, 0..20, cx),
3116 &[
3117 "v project_root",
3118 " > .git",
3119 " > dir_1 <== selected",
3120 " > dir_2",
3121 " .gitignore",
3122 ],
3123 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3124 );
3125 }
3126
3127 cx.update(|cx| {
3128 cx.update_global::<SettingsStore, _>(|store, cx| {
3129 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3130 project_panel_settings.auto_reveal_entries = Some(true)
3131 });
3132 })
3133 });
3134
3135 panel.update(cx, |panel, cx| {
3136 panel.project.update(cx, |_, cx| {
3137 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3138 })
3139 });
3140 cx.run_until_parked();
3141 assert_eq!(
3142 visible_entries_as_strings(&panel, 0..20, cx),
3143 &[
3144 "v project_root",
3145 " > .git",
3146 " v dir_1",
3147 " > gitignored_dir",
3148 " file_1.py <== selected",
3149 " file_2.py",
3150 " file_3.py",
3151 " > dir_2",
3152 " .gitignore",
3153 ],
3154 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3155 );
3156
3157 panel.update(cx, |panel, cx| {
3158 panel.project.update(cx, |_, cx| {
3159 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3160 })
3161 });
3162 cx.run_until_parked();
3163 assert_eq!(
3164 visible_entries_as_strings(&panel, 0..20, cx),
3165 &[
3166 "v project_root",
3167 " > .git",
3168 " v dir_1",
3169 " > gitignored_dir",
3170 " file_1.py",
3171 " file_2.py",
3172 " file_3.py",
3173 " v dir_2",
3174 " file_1.py <== selected",
3175 " file_2.py",
3176 " file_3.py",
3177 " .gitignore",
3178 ],
3179 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3180 );
3181
3182 panel.update(cx, |panel, cx| {
3183 panel.project.update(cx, |_, cx| {
3184 cx.emit(project::Event::ActiveEntryChanged(Some(
3185 gitignored_dir_file,
3186 )))
3187 })
3188 });
3189 cx.run_until_parked();
3190 assert_eq!(
3191 visible_entries_as_strings(&panel, 0..20, cx),
3192 &[
3193 "v project_root",
3194 " > .git",
3195 " v dir_1",
3196 " > gitignored_dir",
3197 " file_1.py",
3198 " file_2.py",
3199 " file_3.py",
3200 " v dir_2",
3201 " file_1.py <== selected",
3202 " file_2.py",
3203 " file_3.py",
3204 " .gitignore",
3205 ],
3206 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3207 );
3208
3209 panel.update(cx, |panel, cx| {
3210 panel.project.update(cx, |_, cx| {
3211 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3212 })
3213 });
3214 cx.run_until_parked();
3215 assert_eq!(
3216 visible_entries_as_strings(&panel, 0..20, cx),
3217 &[
3218 "v project_root",
3219 " > .git",
3220 " v dir_1",
3221 " v gitignored_dir",
3222 " file_a.py <== selected",
3223 " file_b.py",
3224 " file_c.py",
3225 " file_1.py",
3226 " file_2.py",
3227 " file_3.py",
3228 " v dir_2",
3229 " file_1.py",
3230 " file_2.py",
3231 " file_3.py",
3232 " .gitignore",
3233 ],
3234 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3235 );
3236 }
3237
3238 #[gpui::test]
3239 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3240 init_test_with_editor(cx);
3241 cx.update(|cx| {
3242 cx.update_global::<SettingsStore, _>(|store, cx| {
3243 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3244 project_settings.file_scan_exclusions = Some(Vec::new());
3245 });
3246 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3247 project_panel_settings.auto_reveal_entries = Some(false)
3248 });
3249 })
3250 });
3251
3252 let fs = FakeFs::new(cx.background_executor.clone());
3253 fs.insert_tree(
3254 "/project_root",
3255 json!({
3256 ".git": {},
3257 ".gitignore": "**/gitignored_dir",
3258 "dir_1": {
3259 "file_1.py": "# File 1_1 contents",
3260 "file_2.py": "# File 1_2 contents",
3261 "file_3.py": "# File 1_3 contents",
3262 "gitignored_dir": {
3263 "file_a.py": "# File contents",
3264 "file_b.py": "# File contents",
3265 "file_c.py": "# File contents",
3266 },
3267 },
3268 "dir_2": {
3269 "file_1.py": "# File 2_1 contents",
3270 "file_2.py": "# File 2_2 contents",
3271 "file_3.py": "# File 2_3 contents",
3272 }
3273 }),
3274 )
3275 .await;
3276
3277 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3278 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3279 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3280 let panel = workspace
3281 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3282 .unwrap();
3283
3284 assert_eq!(
3285 visible_entries_as_strings(&panel, 0..20, cx),
3286 &[
3287 "v project_root",
3288 " > .git",
3289 " > dir_1",
3290 " > dir_2",
3291 " .gitignore",
3292 ]
3293 );
3294
3295 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3296 .expect("dir 1 file is not ignored and should have an entry");
3297 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3298 .expect("dir 2 file is not ignored and should have an entry");
3299 let gitignored_dir_file =
3300 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3301 assert_eq!(
3302 gitignored_dir_file, None,
3303 "File in the gitignored dir should not have an entry before its dir is toggled"
3304 );
3305
3306 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3307 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3308 cx.run_until_parked();
3309 assert_eq!(
3310 visible_entries_as_strings(&panel, 0..20, cx),
3311 &[
3312 "v project_root",
3313 " > .git",
3314 " v dir_1",
3315 " v gitignored_dir <== selected",
3316 " file_a.py",
3317 " file_b.py",
3318 " file_c.py",
3319 " file_1.py",
3320 " file_2.py",
3321 " file_3.py",
3322 " > dir_2",
3323 " .gitignore",
3324 ],
3325 "Should show gitignored dir file list in the project panel"
3326 );
3327 let gitignored_dir_file =
3328 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3329 .expect("after gitignored dir got opened, a file entry should be present");
3330
3331 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3332 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3333 assert_eq!(
3334 visible_entries_as_strings(&panel, 0..20, cx),
3335 &[
3336 "v project_root",
3337 " > .git",
3338 " > dir_1 <== selected",
3339 " > dir_2",
3340 " .gitignore",
3341 ],
3342 "Should hide all dir contents again and prepare for the explicit reveal test"
3343 );
3344
3345 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3346 panel.update(cx, |panel, cx| {
3347 panel.project.update(cx, |_, cx| {
3348 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3349 })
3350 });
3351 cx.run_until_parked();
3352 assert_eq!(
3353 visible_entries_as_strings(&panel, 0..20, cx),
3354 &[
3355 "v project_root",
3356 " > .git",
3357 " > dir_1 <== selected",
3358 " > dir_2",
3359 " .gitignore",
3360 ],
3361 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3362 );
3363 }
3364
3365 panel.update(cx, |panel, cx| {
3366 panel.project.update(cx, |_, cx| {
3367 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3368 })
3369 });
3370 cx.run_until_parked();
3371 assert_eq!(
3372 visible_entries_as_strings(&panel, 0..20, cx),
3373 &[
3374 "v project_root",
3375 " > .git",
3376 " v dir_1",
3377 " > gitignored_dir",
3378 " file_1.py <== selected",
3379 " file_2.py",
3380 " file_3.py",
3381 " > dir_2",
3382 " .gitignore",
3383 ],
3384 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3385 );
3386
3387 panel.update(cx, |panel, cx| {
3388 panel.project.update(cx, |_, cx| {
3389 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3390 })
3391 });
3392 cx.run_until_parked();
3393 assert_eq!(
3394 visible_entries_as_strings(&panel, 0..20, cx),
3395 &[
3396 "v project_root",
3397 " > .git",
3398 " v dir_1",
3399 " > gitignored_dir",
3400 " file_1.py",
3401 " file_2.py",
3402 " file_3.py",
3403 " v dir_2",
3404 " file_1.py <== selected",
3405 " file_2.py",
3406 " file_3.py",
3407 " .gitignore",
3408 ],
3409 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3410 );
3411
3412 panel.update(cx, |panel, cx| {
3413 panel.project.update(cx, |_, cx| {
3414 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3415 })
3416 });
3417 cx.run_until_parked();
3418 assert_eq!(
3419 visible_entries_as_strings(&panel, 0..20, cx),
3420 &[
3421 "v project_root",
3422 " > .git",
3423 " v dir_1",
3424 " v gitignored_dir",
3425 " file_a.py <== selected",
3426 " file_b.py",
3427 " file_c.py",
3428 " file_1.py",
3429 " file_2.py",
3430 " file_3.py",
3431 " v dir_2",
3432 " file_1.py",
3433 " file_2.py",
3434 " file_3.py",
3435 " .gitignore",
3436 ],
3437 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3438 );
3439 }
3440
3441 fn toggle_expand_dir(
3442 panel: &View<ProjectPanel>,
3443 path: impl AsRef<Path>,
3444 cx: &mut VisualTestContext,
3445 ) {
3446 let path = path.as_ref();
3447 panel.update(cx, |panel, cx| {
3448 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3449 let worktree = worktree.read(cx);
3450 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3451 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3452 panel.toggle_expanded(entry_id, cx);
3453 return;
3454 }
3455 }
3456 panic!("no worktree for path {:?}", path);
3457 });
3458 }
3459
3460 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3461 let path = path.as_ref();
3462 panel.update(cx, |panel, cx| {
3463 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3464 let worktree = worktree.read(cx);
3465 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3466 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3467 panel.selection = Some(crate::Selection {
3468 worktree_id: worktree.id(),
3469 entry_id,
3470 });
3471 return;
3472 }
3473 }
3474 panic!("no worktree for path {:?}", path);
3475 });
3476 }
3477
3478 fn find_project_entry(
3479 panel: &View<ProjectPanel>,
3480 path: impl AsRef<Path>,
3481 cx: &mut VisualTestContext,
3482 ) -> Option<ProjectEntryId> {
3483 let path = path.as_ref();
3484 panel.update(cx, |panel, cx| {
3485 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3486 let worktree = worktree.read(cx);
3487 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3488 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3489 }
3490 }
3491 panic!("no worktree for path {path:?}");
3492 })
3493 }
3494
3495 fn visible_entries_as_strings(
3496 panel: &View<ProjectPanel>,
3497 range: Range<usize>,
3498 cx: &mut VisualTestContext,
3499 ) -> Vec<String> {
3500 let mut result = Vec::new();
3501 let mut project_entries = HashSet::new();
3502 let mut has_editor = false;
3503
3504 panel.update(cx, |panel, cx| {
3505 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3506 if details.is_editing {
3507 assert!(!has_editor, "duplicate editor entry");
3508 has_editor = true;
3509 } else {
3510 assert!(
3511 project_entries.insert(project_entry),
3512 "duplicate project entry {:?} {:?}",
3513 project_entry,
3514 details
3515 );
3516 }
3517
3518 let indent = " ".repeat(details.depth);
3519 let icon = if details.kind.is_dir() {
3520 if details.is_expanded {
3521 "v "
3522 } else {
3523 "> "
3524 }
3525 } else {
3526 " "
3527 };
3528 let name = if details.is_editing {
3529 format!("[EDITOR: '{}']", details.filename)
3530 } else if details.is_processing {
3531 format!("[PROCESSING: '{}']", details.filename)
3532 } else {
3533 details.filename.clone()
3534 };
3535 let selected = if details.is_selected {
3536 " <== selected"
3537 } else {
3538 ""
3539 };
3540 result.push(format!("{indent}{icon}{name}{selected}"));
3541 });
3542 });
3543
3544 result
3545 }
3546
3547 fn init_test(cx: &mut TestAppContext) {
3548 cx.update(|cx| {
3549 let settings_store = SettingsStore::test(cx);
3550 cx.set_global(settings_store);
3551 init_settings(cx);
3552 theme::init(theme::LoadThemes::JustBase, cx);
3553 language::init(cx);
3554 editor::init_settings(cx);
3555 crate::init((), cx);
3556 workspace::init_settings(cx);
3557 client::init_settings(cx);
3558 Project::init_settings(cx);
3559
3560 cx.update_global::<SettingsStore, _>(|store, cx| {
3561 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3562 project_settings.file_scan_exclusions = Some(Vec::new());
3563 });
3564 });
3565 });
3566 }
3567
3568 fn init_test_with_editor(cx: &mut TestAppContext) {
3569 cx.update(|cx| {
3570 let app_state = AppState::test(cx);
3571 theme::init(theme::LoadThemes::JustBase, cx);
3572 init_settings(cx);
3573 language::init(cx);
3574 editor::init(cx);
3575 crate::init((), cx);
3576 workspace::init(app_state.clone(), cx);
3577 Project::init_settings(cx);
3578 });
3579 }
3580
3581 fn ensure_single_file_is_opened(
3582 window: &WindowHandle<Workspace>,
3583 expected_path: &str,
3584 cx: &mut TestAppContext,
3585 ) {
3586 window
3587 .update(cx, |workspace, cx| {
3588 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3589 assert_eq!(worktrees.len(), 1);
3590 let worktree_id = worktrees[0].read(cx).id();
3591
3592 let open_project_paths = workspace
3593 .panes()
3594 .iter()
3595 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3596 .collect::<Vec<_>>();
3597 assert_eq!(
3598 open_project_paths,
3599 vec![ProjectPath {
3600 worktree_id,
3601 path: Arc::from(Path::new(expected_path))
3602 }],
3603 "Should have opened file, selected in project panel"
3604 );
3605 })
3606 .unwrap();
3607 }
3608
3609 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3610 assert!(
3611 !cx.has_pending_prompt(),
3612 "Should have no prompts before the deletion"
3613 );
3614 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3615 assert!(
3616 cx.has_pending_prompt(),
3617 "Should have a prompt after the deletion"
3618 );
3619 cx.simulate_prompt_answer(0);
3620 assert!(
3621 !cx.has_pending_prompt(),
3622 "Should have no prompts after prompt was replied to"
3623 );
3624 cx.executor().run_until_parked();
3625 }
3626
3627 fn ensure_no_open_items_and_panes(
3628 workspace: &WindowHandle<Workspace>,
3629 cx: &mut VisualTestContext,
3630 ) {
3631 assert!(
3632 !cx.has_pending_prompt(),
3633 "Should have no prompts after deletion operation closes the file"
3634 );
3635 workspace
3636 .read_with(cx, |workspace, cx| {
3637 let open_project_paths = workspace
3638 .panes()
3639 .iter()
3640 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3641 .collect::<Vec<_>>();
3642 assert!(
3643 open_project_paths.is_empty(),
3644 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3645 );
3646 })
3647 .unwrap();
3648 }
3649}