1pub mod file_associations;
2mod project_panel_settings;
3
4use context_menu::{ContextMenu, ContextMenuItem};
5use db::kvp::KEY_VALUE_STORE;
6use drag_and_drop::{DragAndDrop, Draggable};
7use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
8use file_associations::FileAssociations;
9
10use futures::stream::StreamExt;
11use gpui::{
12 actions,
13 anyhow::{self, anyhow, Result},
14 elements::{
15 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
16 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
17 },
18 geometry::vector::Vector2F,
19 keymap_matcher::KeymapContext,
20 platform::{CursorStyle, MouseButton, PromptLevel},
21 Action, AnyElement, AppContext, AssetSource, AsyncAppContext, ClipboardItem, Element, Entity,
22 ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
23};
24use menu::{Confirm, SelectNext, SelectPrev};
25use project::{
26 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
27 Worktree, WorktreeId,
28};
29use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
30use serde::{Deserialize, Serialize};
31use settings::SettingsStore;
32use std::{
33 cmp::Ordering,
34 collections::{hash_map, HashMap},
35 ffi::OsStr,
36 ops::Range,
37 path::Path,
38 sync::Arc,
39};
40use theme::ProjectPanelEntry;
41use unicase::UniCase;
42use util::{ResultExt, TryFutureExt};
43use workspace::{
44 dock::{DockPosition, Panel},
45 Workspace,
46};
47
48const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
49const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
50
51pub struct ProjectPanel {
52 project: ModelHandle<Project>,
53 fs: Arc<dyn Fs>,
54 list: UniformListState,
55 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
56 last_worktree_root_id: Option<ProjectEntryId>,
57 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
58 selection: Option<Selection>,
59 edit_state: Option<EditState>,
60 filename_editor: ViewHandle<Editor>,
61 clipboard_entry: Option<ClipboardEntry>,
62 context_menu: ViewHandle<ContextMenu>,
63 dragged_entry_destination: Option<Arc<Path>>,
64 workspace: WeakViewHandle<Workspace>,
65 has_focus: bool,
66 width: Option<f32>,
67 pending_serialization: Task<Option<()>>,
68}
69
70#[derive(Copy, Clone, Debug)]
71struct Selection {
72 worktree_id: WorktreeId,
73 entry_id: ProjectEntryId,
74}
75
76#[derive(Clone, Debug)]
77struct EditState {
78 worktree_id: WorktreeId,
79 entry_id: ProjectEntryId,
80 is_new_entry: bool,
81 is_dir: bool,
82 processing_filename: Option<String>,
83}
84
85#[derive(Copy, Clone)]
86pub enum ClipboardEntry {
87 Copied {
88 worktree_id: WorktreeId,
89 entry_id: ProjectEntryId,
90 },
91 Cut {
92 worktree_id: WorktreeId,
93 entry_id: ProjectEntryId,
94 },
95}
96
97#[derive(Debug, PartialEq, Eq)]
98pub struct EntryDetails {
99 filename: String,
100 icon: Option<Arc<str>>,
101 path: Arc<Path>,
102 depth: usize,
103 kind: EntryKind,
104 is_ignored: bool,
105 is_expanded: bool,
106 is_selected: bool,
107 is_editing: bool,
108 is_processing: bool,
109 is_cut: bool,
110 git_status: Option<GitFileStatus>,
111}
112
113actions!(
114 project_panel,
115 [
116 ExpandSelectedEntry,
117 CollapseSelectedEntry,
118 NewDirectory,
119 NewFile,
120 Copy,
121 CopyPath,
122 CopyRelativePath,
123 RevealInFinder,
124 Cut,
125 Paste,
126 Delete,
127 Rename,
128 ToggleFocus,
129 NewSearchInDirectory,
130 ]
131);
132
133pub fn init_settings(cx: &mut AppContext) {
134 settings::register::<ProjectPanelSettings>(cx);
135}
136
137pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
138 init_settings(cx);
139 file_associations::init(assets, cx);
140 cx.add_action(ProjectPanel::expand_selected_entry);
141 cx.add_action(ProjectPanel::collapse_selected_entry);
142 cx.add_action(ProjectPanel::select_prev);
143 cx.add_action(ProjectPanel::select_next);
144 cx.add_action(ProjectPanel::new_file);
145 cx.add_action(ProjectPanel::new_directory);
146 cx.add_action(ProjectPanel::rename);
147 cx.add_async_action(ProjectPanel::delete);
148 cx.add_async_action(ProjectPanel::confirm);
149 cx.add_action(ProjectPanel::cancel);
150 cx.add_action(ProjectPanel::cut);
151 cx.add_action(ProjectPanel::copy);
152 cx.add_action(ProjectPanel::copy_path);
153 cx.add_action(ProjectPanel::copy_relative_path);
154 cx.add_action(ProjectPanel::reveal_in_finder);
155 cx.add_action(ProjectPanel::new_search_in_directory);
156 cx.add_action(
157 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
158 this.paste(action, cx);
159 },
160 );
161}
162
163#[derive(Debug)]
164pub enum Event {
165 OpenedEntry {
166 entry_id: ProjectEntryId,
167 focus_opened_item: bool,
168 },
169 SplitEntry {
170 entry_id: ProjectEntryId,
171 },
172 DockPositionChanged,
173 Focus,
174 NewSearchInDirectory {
175 dir_entry: Entry,
176 },
177}
178
179#[derive(Serialize, Deserialize)]
180struct SerializedProjectPanel {
181 width: Option<f32>,
182}
183
184impl ProjectPanel {
185 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
186 let project = workspace.project().clone();
187 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
188 cx.observe(&project, |this, _, cx| {
189 this.update_visible_entries(None, cx);
190 cx.notify();
191 })
192 .detach();
193 cx.subscribe(&project, |this, project, event, cx| match event {
194 project::Event::ActiveEntryChanged(Some(entry_id)) => {
195 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
196 {
197 this.expand_entry(worktree_id, *entry_id, cx);
198 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
199 this.autoscroll(cx);
200 cx.notify();
201 }
202 }
203 project::Event::WorktreeRemoved(id) => {
204 this.expanded_dir_ids.remove(id);
205 this.update_visible_entries(None, cx);
206 cx.notify();
207 }
208 _ => {}
209 })
210 .detach();
211
212 let filename_editor = cx.add_view(|cx| {
213 Editor::single_line(
214 Some(Arc::new(|theme| {
215 let mut style = theme.project_panel.filename_editor.clone();
216 style.container.background_color.take();
217 style
218 })),
219 cx,
220 )
221 });
222
223 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
224 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
225 this.autoscroll(cx);
226 }
227 _ => {}
228 })
229 .detach();
230 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
231 if !is_focused
232 && this
233 .edit_state
234 .as_ref()
235 .map_or(false, |state| state.processing_filename.is_none())
236 {
237 this.edit_state = None;
238 this.update_visible_entries(None, cx);
239 }
240 })
241 .detach();
242
243 cx.observe_global::<FileAssociations, _>(|_, cx| {
244 cx.notify();
245 })
246 .detach();
247
248 let view_id = cx.view_id();
249 let mut this = Self {
250 project: project.clone(),
251 fs: workspace.app_state().fs.clone(),
252 list: Default::default(),
253 visible_entries: Default::default(),
254 last_worktree_root_id: Default::default(),
255 expanded_dir_ids: Default::default(),
256 selection: None,
257 edit_state: None,
258 filename_editor,
259 clipboard_entry: None,
260 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
261 dragged_entry_destination: None,
262 workspace: workspace.weak_handle(),
263 has_focus: false,
264 width: None,
265 pending_serialization: Task::ready(None),
266 };
267 this.update_visible_entries(None, cx);
268
269 // Update the dock position when the setting changes.
270 let mut old_dock_position = this.position(cx);
271 cx.observe_global::<SettingsStore, _>(move |this, cx| {
272 let new_dock_position = this.position(cx);
273 if new_dock_position != old_dock_position {
274 old_dock_position = new_dock_position;
275 cx.emit(Event::DockPositionChanged);
276 }
277 })
278 .detach();
279
280 this
281 });
282
283 cx.subscribe(&project_panel, {
284 let project_panel = project_panel.downgrade();
285 move |workspace, _, event, cx| match event {
286 &Event::OpenedEntry {
287 entry_id,
288 focus_opened_item,
289 } => {
290 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
291 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
292 workspace
293 .open_path(
294 ProjectPath {
295 worktree_id: worktree.read(cx).id(),
296 path: entry.path.clone(),
297 },
298 None,
299 focus_opened_item,
300 cx,
301 )
302 .detach_and_log_err(cx);
303 if !focus_opened_item {
304 if let Some(project_panel) = project_panel.upgrade(cx) {
305 cx.focus(&project_panel);
306 }
307 }
308 }
309 }
310 }
311 &Event::SplitEntry { entry_id } => {
312 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
313 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
314 workspace
315 .split_path(
316 ProjectPath {
317 worktree_id: worktree.read(cx).id(),
318 path: entry.path.clone(),
319 },
320 cx,
321 )
322 .detach_and_log_err(cx);
323 }
324 }
325 }
326 _ => {}
327 }
328 })
329 .detach();
330
331 project_panel
332 }
333
334 pub fn load(
335 workspace: WeakViewHandle<Workspace>,
336 cx: AsyncAppContext,
337 ) -> Task<Result<ViewHandle<Self>>> {
338 cx.spawn(|mut cx| async move {
339 let serialized_panel = if let Some(panel) = cx
340 .background()
341 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
342 .await
343 .log_err()
344 .flatten()
345 {
346 Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
347 } else {
348 None
349 };
350 workspace.update(&mut cx, |workspace, cx| {
351 let panel = ProjectPanel::new(workspace, cx);
352 if let Some(serialized_panel) = serialized_panel {
353 panel.update(cx, |panel, cx| {
354 panel.width = serialized_panel.width;
355 cx.notify();
356 });
357 }
358 panel
359 })
360 })
361 }
362
363 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
364 let width = self.width;
365 self.pending_serialization = cx.background().spawn(
366 async move {
367 KEY_VALUE_STORE
368 .write_kvp(
369 PROJECT_PANEL_KEY.into(),
370 serde_json::to_string(&SerializedProjectPanel { width })?,
371 )
372 .await?;
373 anyhow::Ok(())
374 }
375 .log_err(),
376 );
377 }
378
379 fn deploy_context_menu(
380 &mut self,
381 position: Vector2F,
382 entry_id: ProjectEntryId,
383 cx: &mut ViewContext<Self>,
384 ) {
385 let project = self.project.read(cx);
386
387 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
388 id
389 } else {
390 return;
391 };
392
393 self.selection = Some(Selection {
394 worktree_id,
395 entry_id,
396 });
397
398 let mut menu_entries = Vec::new();
399 if let Some((worktree, entry)) = self.selected_entry(cx) {
400 let is_root = Some(entry) == worktree.root_entry();
401 if !project.is_remote() {
402 menu_entries.push(ContextMenuItem::action(
403 "Add Folder to Project",
404 workspace::AddFolderToProject,
405 ));
406 if is_root {
407 let project = self.project.clone();
408 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
409 project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
410 }));
411 }
412 }
413 menu_entries.push(ContextMenuItem::action("New File", NewFile));
414 menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
415 menu_entries.push(ContextMenuItem::Separator);
416 menu_entries.push(ContextMenuItem::action("Cut", Cut));
417 menu_entries.push(ContextMenuItem::action("Copy", Copy));
418 menu_entries.push(ContextMenuItem::Separator);
419 menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
420 menu_entries.push(ContextMenuItem::action(
421 "Copy Relative Path",
422 CopyRelativePath,
423 ));
424 menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
425 if entry.is_dir() {
426 menu_entries.push(ContextMenuItem::action(
427 "Search inside",
428 NewSearchInDirectory,
429 ));
430 }
431 if let Some(clipboard_entry) = self.clipboard_entry {
432 if clipboard_entry.worktree_id() == worktree.id() {
433 menu_entries.push(ContextMenuItem::action("Paste", Paste));
434 }
435 }
436 menu_entries.push(ContextMenuItem::Separator);
437 menu_entries.push(ContextMenuItem::action("Rename", Rename));
438 if !is_root {
439 menu_entries.push(ContextMenuItem::action("Delete", Delete));
440 }
441 }
442
443 self.context_menu.update(cx, |menu, cx| {
444 menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
445 });
446
447 cx.notify();
448 }
449
450 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
451 if let Some((worktree, entry)) = self.selected_entry(cx) {
452 if entry.is_dir() {
453 let worktree_id = worktree.id();
454 let entry_id = entry.id;
455 let expanded_dir_ids =
456 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
457 expanded_dir_ids
458 } else {
459 return;
460 };
461
462 match expanded_dir_ids.binary_search(&entry_id) {
463 Ok(_) => self.select_next(&SelectNext, cx),
464 Err(ix) => {
465 self.project.update(cx, |project, cx| {
466 project.expand_entry(worktree_id, entry_id, cx);
467 });
468
469 expanded_dir_ids.insert(ix, entry_id);
470 self.update_visible_entries(None, cx);
471 cx.notify();
472 }
473 }
474 }
475 }
476 }
477
478 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
479 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
480 let worktree_id = worktree.id();
481 let expanded_dir_ids =
482 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
483 expanded_dir_ids
484 } else {
485 return;
486 };
487
488 loop {
489 let entry_id = entry.id;
490 match expanded_dir_ids.binary_search(&entry_id) {
491 Ok(ix) => {
492 expanded_dir_ids.remove(ix);
493 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
494 cx.notify();
495 break;
496 }
497 Err(_) => {
498 if let Some(parent_entry) =
499 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
500 {
501 entry = parent_entry;
502 } else {
503 break;
504 }
505 }
506 }
507 }
508 }
509 }
510
511 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
512 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
513 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
514 self.project.update(cx, |project, cx| {
515 match expanded_dir_ids.binary_search(&entry_id) {
516 Ok(ix) => {
517 expanded_dir_ids.remove(ix);
518 }
519 Err(ix) => {
520 project.expand_entry(worktree_id, entry_id, cx);
521 expanded_dir_ids.insert(ix, entry_id);
522 }
523 }
524 });
525 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
526 cx.focus_self();
527 cx.notify();
528 }
529 }
530 }
531
532 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
533 if let Some(selection) = self.selection {
534 let (mut worktree_ix, mut entry_ix, _) =
535 self.index_for_selection(selection).unwrap_or_default();
536 if entry_ix > 0 {
537 entry_ix -= 1;
538 } else if worktree_ix > 0 {
539 worktree_ix -= 1;
540 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
541 } else {
542 return;
543 }
544
545 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
546 self.selection = Some(Selection {
547 worktree_id: *worktree_id,
548 entry_id: worktree_entries[entry_ix].id,
549 });
550 self.autoscroll(cx);
551 cx.notify();
552 } else {
553 self.select_first(cx);
554 }
555 }
556
557 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
558 if let Some(task) = self.confirm_edit(cx) {
559 Some(task)
560 } else if let Some((_, entry)) = self.selected_entry(cx) {
561 if entry.is_file() {
562 self.open_entry(entry.id, true, cx);
563 }
564 None
565 } else {
566 None
567 }
568 }
569
570 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
571 let edit_state = self.edit_state.as_mut()?;
572 cx.focus_self();
573
574 let worktree_id = edit_state.worktree_id;
575 let is_new_entry = edit_state.is_new_entry;
576 let is_dir = edit_state.is_dir;
577 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
578 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
579 let filename = self.filename_editor.read(cx).text(cx);
580
581 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
582 let edit_task;
583 let edited_entry_id;
584 if is_new_entry {
585 self.selection = Some(Selection {
586 worktree_id,
587 entry_id: NEW_ENTRY_ID,
588 });
589 let new_path = entry.path.join(&filename.trim_start_matches("/"));
590 if path_already_exists(new_path.as_path()) {
591 return None;
592 }
593
594 edited_entry_id = NEW_ENTRY_ID;
595 edit_task = self.project.update(cx, |project, cx| {
596 project.create_entry((worktree_id, &new_path), is_dir, cx)
597 })?;
598 } else {
599 let new_path = if let Some(parent) = entry.path.clone().parent() {
600 parent.join(&filename)
601 } else {
602 filename.clone().into()
603 };
604 if path_already_exists(new_path.as_path()) {
605 return None;
606 }
607
608 edited_entry_id = entry.id;
609 edit_task = self.project.update(cx, |project, cx| {
610 project.rename_entry(entry.id, new_path.as_path(), cx)
611 })?;
612 };
613
614 edit_state.processing_filename = Some(filename);
615 cx.notify();
616
617 Some(cx.spawn(|this, mut cx| async move {
618 let new_entry = edit_task.await;
619 this.update(&mut cx, |this, cx| {
620 this.edit_state.take();
621 cx.notify();
622 })?;
623
624 let new_entry = new_entry?;
625 this.update(&mut cx, |this, cx| {
626 if let Some(selection) = &mut this.selection {
627 if selection.entry_id == edited_entry_id {
628 selection.worktree_id = worktree_id;
629 selection.entry_id = new_entry.id;
630 this.expand_to_selection(cx);
631 }
632 }
633 this.update_visible_entries(None, cx);
634 if is_new_entry && !is_dir {
635 this.open_entry(new_entry.id, true, cx);
636 }
637 cx.notify();
638 })?;
639 Ok(())
640 }))
641 }
642
643 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
644 self.edit_state = None;
645 self.update_visible_entries(None, cx);
646 cx.focus_self();
647 cx.notify();
648 }
649
650 fn open_entry(
651 &mut self,
652 entry_id: ProjectEntryId,
653 focus_opened_item: bool,
654 cx: &mut ViewContext<Self>,
655 ) {
656 cx.emit(Event::OpenedEntry {
657 entry_id,
658 focus_opened_item,
659 });
660 }
661
662 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
663 cx.emit(Event::SplitEntry { entry_id });
664 }
665
666 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
667 self.add_entry(false, cx)
668 }
669
670 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
671 self.add_entry(true, cx)
672 }
673
674 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
675 if let Some(Selection {
676 worktree_id,
677 entry_id,
678 }) = self.selection
679 {
680 let directory_id;
681 if let Some((worktree, expanded_dir_ids)) = self
682 .project
683 .read(cx)
684 .worktree_for_id(worktree_id, cx)
685 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
686 {
687 let worktree = worktree.read(cx);
688 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
689 loop {
690 if entry.is_dir() {
691 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
692 expanded_dir_ids.insert(ix, entry.id);
693 }
694 directory_id = entry.id;
695 break;
696 } else {
697 if let Some(parent_path) = entry.path.parent() {
698 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
699 entry = parent_entry;
700 continue;
701 }
702 }
703 return;
704 }
705 }
706 } else {
707 return;
708 };
709 } else {
710 return;
711 };
712
713 self.edit_state = Some(EditState {
714 worktree_id,
715 entry_id: directory_id,
716 is_new_entry: true,
717 is_dir,
718 processing_filename: None,
719 });
720 self.filename_editor
721 .update(cx, |editor, cx| editor.clear(cx));
722 cx.focus(&self.filename_editor);
723 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
724 self.autoscroll(cx);
725 cx.notify();
726 }
727 }
728
729 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
730 if let Some(Selection {
731 worktree_id,
732 entry_id,
733 }) = self.selection
734 {
735 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
736 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
737 self.edit_state = Some(EditState {
738 worktree_id,
739 entry_id,
740 is_new_entry: false,
741 is_dir: entry.is_dir(),
742 processing_filename: None,
743 });
744 let file_name = entry
745 .path
746 .file_name()
747 .map(|s| s.to_string_lossy())
748 .unwrap_or_default()
749 .to_string();
750 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
751 let selection_end =
752 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
753 self.filename_editor.update(cx, |editor, cx| {
754 editor.set_text(file_name, cx);
755 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
756 s.select_ranges([0..selection_end])
757 })
758 });
759 cx.focus(&self.filename_editor);
760 self.update_visible_entries(None, cx);
761 self.autoscroll(cx);
762 cx.notify();
763 }
764 }
765
766 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
767 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
768 })
769 }
770 }
771
772 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
773 let Selection { entry_id, .. } = self.selection?;
774 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
775 let file_name = path.file_name()?;
776
777 let mut answer = cx.prompt(
778 PromptLevel::Info,
779 &format!("Delete {file_name:?}?"),
780 &["Delete", "Cancel"],
781 );
782 Some(cx.spawn(|this, mut cx| async move {
783 if answer.next().await != Some(0) {
784 return Ok(());
785 }
786 this.update(&mut cx, |this, cx| {
787 this.project
788 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
789 .ok_or_else(|| anyhow!("no such entry"))
790 })??
791 .await
792 }))
793 }
794
795 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
796 if let Some(selection) = self.selection {
797 let (mut worktree_ix, mut entry_ix, _) =
798 self.index_for_selection(selection).unwrap_or_default();
799 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
800 if entry_ix + 1 < worktree_entries.len() {
801 entry_ix += 1;
802 } else {
803 worktree_ix += 1;
804 entry_ix = 0;
805 }
806 }
807
808 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
809 if let Some(entry) = worktree_entries.get(entry_ix) {
810 self.selection = Some(Selection {
811 worktree_id: *worktree_id,
812 entry_id: entry.id,
813 });
814 self.autoscroll(cx);
815 cx.notify();
816 }
817 }
818 } else {
819 self.select_first(cx);
820 }
821 }
822
823 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
824 let worktree = self
825 .visible_entries
826 .first()
827 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
828 if let Some(worktree) = worktree {
829 let worktree = worktree.read(cx);
830 let worktree_id = worktree.id();
831 if let Some(root_entry) = worktree.root_entry() {
832 self.selection = Some(Selection {
833 worktree_id,
834 entry_id: root_entry.id,
835 });
836 self.autoscroll(cx);
837 cx.notify();
838 }
839 }
840 }
841
842 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
843 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
844 self.list.scroll_to(ScrollTarget::Show(index));
845 cx.notify();
846 }
847 }
848
849 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
850 if let Some((worktree, entry)) = self.selected_entry(cx) {
851 self.clipboard_entry = Some(ClipboardEntry::Cut {
852 worktree_id: worktree.id(),
853 entry_id: entry.id,
854 });
855 cx.notify();
856 }
857 }
858
859 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
860 if let Some((worktree, entry)) = self.selected_entry(cx) {
861 self.clipboard_entry = Some(ClipboardEntry::Copied {
862 worktree_id: worktree.id(),
863 entry_id: entry.id,
864 });
865 cx.notify();
866 }
867 }
868
869 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
870 if let Some((worktree, entry)) = self.selected_entry(cx) {
871 let clipboard_entry = self.clipboard_entry?;
872 if clipboard_entry.worktree_id() != worktree.id() {
873 return None;
874 }
875
876 let clipboard_entry_file_name = self
877 .project
878 .read(cx)
879 .path_for_entry(clipboard_entry.entry_id(), cx)?
880 .path
881 .file_name()?
882 .to_os_string();
883
884 let mut new_path = entry.path.to_path_buf();
885 if entry.is_file() {
886 new_path.pop();
887 }
888
889 new_path.push(&clipboard_entry_file_name);
890 let extension = new_path.extension().map(|e| e.to_os_string());
891 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
892 let mut ix = 0;
893 while worktree.entry_for_path(&new_path).is_some() {
894 new_path.pop();
895
896 let mut new_file_name = file_name_without_extension.to_os_string();
897 new_file_name.push(" copy");
898 if ix > 0 {
899 new_file_name.push(format!(" {}", ix));
900 }
901 if let Some(extension) = extension.as_ref() {
902 new_file_name.push(".");
903 new_file_name.push(extension);
904 }
905
906 new_path.push(new_file_name);
907 ix += 1;
908 }
909
910 if clipboard_entry.is_cut() {
911 if let Some(task) = self.project.update(cx, |project, cx| {
912 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
913 }) {
914 task.detach_and_log_err(cx)
915 }
916 } else if let Some(task) = self.project.update(cx, |project, cx| {
917 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
918 }) {
919 task.detach_and_log_err(cx)
920 }
921 }
922 None
923 }
924
925 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
926 if let Some((worktree, entry)) = self.selected_entry(cx) {
927 cx.write_to_clipboard(ClipboardItem::new(
928 worktree
929 .abs_path()
930 .join(&entry.path)
931 .to_string_lossy()
932 .to_string(),
933 ));
934 }
935 }
936
937 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
938 if let Some((_, entry)) = self.selected_entry(cx) {
939 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
940 }
941 }
942
943 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
944 if let Some((worktree, entry)) = self.selected_entry(cx) {
945 cx.reveal_path(&worktree.abs_path().join(&entry.path));
946 }
947 }
948
949 pub fn new_search_in_directory(
950 &mut self,
951 _: &NewSearchInDirectory,
952 cx: &mut ViewContext<Self>,
953 ) {
954 if let Some((_, entry)) = self.selected_entry(cx) {
955 if entry.is_dir() {
956 cx.emit(Event::NewSearchInDirectory {
957 dir_entry: entry.clone(),
958 });
959 }
960 }
961 }
962
963 fn move_entry(
964 &mut self,
965 entry_to_move: ProjectEntryId,
966 destination: ProjectEntryId,
967 destination_is_file: bool,
968 cx: &mut ViewContext<Self>,
969 ) {
970 let destination_worktree = self.project.update(cx, |project, cx| {
971 let entry_path = project.path_for_entry(entry_to_move, cx)?;
972 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
973
974 let mut destination_path = destination_entry_path.as_ref();
975 if destination_is_file {
976 destination_path = destination_path.parent()?;
977 }
978
979 let mut new_path = destination_path.to_path_buf();
980 new_path.push(entry_path.path.file_name()?);
981 if new_path != entry_path.path.as_ref() {
982 let task = project.rename_entry(entry_to_move, new_path, cx)?;
983 cx.foreground().spawn(task).detach_and_log_err(cx);
984 }
985
986 Some(project.worktree_id_for_entry(destination, cx)?)
987 });
988
989 if let Some(destination_worktree) = destination_worktree {
990 self.expand_entry(destination_worktree, destination, cx);
991 }
992 }
993
994 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
995 let mut entry_index = 0;
996 let mut visible_entries_index = 0;
997 for (worktree_index, (worktree_id, worktree_entries)) in
998 self.visible_entries.iter().enumerate()
999 {
1000 if *worktree_id == selection.worktree_id {
1001 for entry in worktree_entries {
1002 if entry.id == selection.entry_id {
1003 return Some((worktree_index, entry_index, visible_entries_index));
1004 } else {
1005 visible_entries_index += 1;
1006 entry_index += 1;
1007 }
1008 }
1009 break;
1010 } else {
1011 visible_entries_index += worktree_entries.len();
1012 }
1013 }
1014 None
1015 }
1016
1017 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
1018 let (worktree, entry) = self.selected_entry_handle(cx)?;
1019 Some((worktree.read(cx), entry))
1020 }
1021
1022 fn selected_entry_handle<'a>(
1023 &self,
1024 cx: &'a AppContext,
1025 ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1026 let selection = self.selection?;
1027 let project = self.project.read(cx);
1028 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1029 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1030 Some((worktree, entry))
1031 }
1032
1033 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1034 let (worktree, entry) = self.selected_entry(cx)?;
1035 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1036
1037 for path in entry.path.ancestors() {
1038 let Some(entry) = worktree.entry_for_path(path) else {
1039 continue;
1040 };
1041 if entry.is_dir() {
1042 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1043 expanded_dir_ids.insert(idx, entry.id);
1044 }
1045 }
1046 }
1047
1048 Some(())
1049 }
1050
1051 fn update_visible_entries(
1052 &mut self,
1053 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1054 cx: &mut ViewContext<Self>,
1055 ) {
1056 let project = self.project.read(cx);
1057 self.last_worktree_root_id = project
1058 .visible_worktrees(cx)
1059 .rev()
1060 .next()
1061 .and_then(|worktree| worktree.read(cx).root_entry())
1062 .map(|entry| entry.id);
1063
1064 self.visible_entries.clear();
1065 for worktree in project.visible_worktrees(cx) {
1066 let snapshot = worktree.read(cx).snapshot();
1067 let worktree_id = snapshot.id();
1068
1069 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1070 hash_map::Entry::Occupied(e) => e.into_mut(),
1071 hash_map::Entry::Vacant(e) => {
1072 // The first time a worktree's root entry becomes available,
1073 // mark that root entry as expanded.
1074 if let Some(entry) = snapshot.root_entry() {
1075 e.insert(vec![entry.id]).as_slice()
1076 } else {
1077 &[]
1078 }
1079 }
1080 };
1081
1082 let mut new_entry_parent_id = None;
1083 let mut new_entry_kind = EntryKind::Dir;
1084 if let Some(edit_state) = &self.edit_state {
1085 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1086 new_entry_parent_id = Some(edit_state.entry_id);
1087 new_entry_kind = if edit_state.is_dir {
1088 EntryKind::Dir
1089 } else {
1090 EntryKind::File(Default::default())
1091 };
1092 }
1093 }
1094
1095 let mut visible_worktree_entries = Vec::new();
1096 let mut entry_iter = snapshot.entries(true);
1097
1098 while let Some(entry) = entry_iter.entry() {
1099 visible_worktree_entries.push(entry.clone());
1100 if Some(entry.id) == new_entry_parent_id {
1101 visible_worktree_entries.push(Entry {
1102 id: NEW_ENTRY_ID,
1103 kind: new_entry_kind,
1104 path: entry.path.join("\0").into(),
1105 inode: 0,
1106 mtime: entry.mtime,
1107 is_symlink: false,
1108 is_ignored: false,
1109 is_external: false,
1110 git_status: entry.git_status,
1111 });
1112 }
1113 if expanded_dir_ids.binary_search(&entry.id).is_err()
1114 && entry_iter.advance_to_sibling()
1115 {
1116 continue;
1117 }
1118 entry_iter.advance();
1119 }
1120
1121 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1122
1123 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1124 let mut components_a = entry_a.path.components().peekable();
1125 let mut components_b = entry_b.path.components().peekable();
1126 loop {
1127 match (components_a.next(), components_b.next()) {
1128 (Some(component_a), Some(component_b)) => {
1129 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1130 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1131 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1132 let name_a =
1133 UniCase::new(component_a.as_os_str().to_string_lossy());
1134 let name_b =
1135 UniCase::new(component_b.as_os_str().to_string_lossy());
1136 name_a.cmp(&name_b)
1137 });
1138 if !ordering.is_eq() {
1139 return ordering;
1140 }
1141 }
1142 (Some(_), None) => break Ordering::Greater,
1143 (None, Some(_)) => break Ordering::Less,
1144 (None, None) => break Ordering::Equal,
1145 }
1146 }
1147 });
1148 self.visible_entries
1149 .push((worktree_id, visible_worktree_entries));
1150 }
1151
1152 if let Some((worktree_id, entry_id)) = new_selected_entry {
1153 self.selection = Some(Selection {
1154 worktree_id,
1155 entry_id,
1156 });
1157 }
1158 }
1159
1160 fn expand_entry(
1161 &mut self,
1162 worktree_id: WorktreeId,
1163 entry_id: ProjectEntryId,
1164 cx: &mut ViewContext<Self>,
1165 ) {
1166 self.project.update(cx, |project, cx| {
1167 if let Some((worktree, expanded_dir_ids)) = project
1168 .worktree_for_id(worktree_id, cx)
1169 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1170 {
1171 project.expand_entry(worktree_id, entry_id, cx);
1172 let worktree = worktree.read(cx);
1173
1174 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1175 loop {
1176 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1177 expanded_dir_ids.insert(ix, entry.id);
1178 }
1179
1180 if let Some(parent_entry) =
1181 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1182 {
1183 entry = parent_entry;
1184 } else {
1185 break;
1186 }
1187 }
1188 }
1189 }
1190 });
1191 }
1192
1193 fn for_each_visible_entry(
1194 &self,
1195 range: Range<usize>,
1196 cx: &mut ViewContext<ProjectPanel>,
1197 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1198 ) {
1199 let mut ix = 0;
1200 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1201 if ix >= range.end {
1202 return;
1203 }
1204
1205 if ix + visible_worktree_entries.len() <= range.start {
1206 ix += visible_worktree_entries.len();
1207 continue;
1208 }
1209
1210 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1211 let (git_status_setting, show_file_icons, show_folder_icons) = {
1212 let settings = settings::get::<ProjectPanelSettings>(cx);
1213 (
1214 settings.git_status,
1215 settings.file_icons,
1216 settings.folder_icons,
1217 )
1218 };
1219 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1220 let snapshot = worktree.read(cx).snapshot();
1221 let root_name = OsStr::new(snapshot.root_name());
1222 let expanded_entry_ids = self
1223 .expanded_dir_ids
1224 .get(&snapshot.id())
1225 .map(Vec::as_slice)
1226 .unwrap_or(&[]);
1227
1228 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1229 for entry in visible_worktree_entries[entry_range].iter() {
1230 let status = git_status_setting.then(|| entry.git_status).flatten();
1231 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1232 let icon = match entry.kind {
1233 EntryKind::File(_) => {
1234 if show_file_icons {
1235 Some(FileAssociations::get_icon(&entry.path, cx))
1236 } else {
1237 None
1238 }
1239 }
1240 _ => {
1241 if show_folder_icons {
1242 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1243 } else {
1244 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1245 }
1246 }
1247 };
1248
1249 let mut details = EntryDetails {
1250 filename: entry
1251 .path
1252 .file_name()
1253 .unwrap_or(root_name)
1254 .to_string_lossy()
1255 .to_string(),
1256 icon,
1257 path: entry.path.clone(),
1258 depth: entry.path.components().count(),
1259 kind: entry.kind,
1260 is_ignored: entry.is_ignored,
1261 is_expanded,
1262 is_selected: self.selection.map_or(false, |e| {
1263 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1264 }),
1265 is_editing: false,
1266 is_processing: false,
1267 is_cut: self
1268 .clipboard_entry
1269 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1270 git_status: status,
1271 };
1272
1273 if let Some(edit_state) = &self.edit_state {
1274 let is_edited_entry = if edit_state.is_new_entry {
1275 entry.id == NEW_ENTRY_ID
1276 } else {
1277 entry.id == edit_state.entry_id
1278 };
1279
1280 if is_edited_entry {
1281 if let Some(processing_filename) = &edit_state.processing_filename {
1282 details.is_processing = true;
1283 details.filename.clear();
1284 details.filename.push_str(processing_filename);
1285 } else {
1286 if edit_state.is_new_entry {
1287 details.filename.clear();
1288 }
1289 details.is_editing = true;
1290 }
1291 }
1292 }
1293
1294 callback(entry.id, details, cx);
1295 }
1296 }
1297 ix = end_ix;
1298 }
1299 }
1300
1301 fn render_entry_visual_element<V: View>(
1302 details: &EntryDetails,
1303 editor: Option<&ViewHandle<Editor>>,
1304 padding: f32,
1305 row_container_style: ContainerStyle,
1306 style: &ProjectPanelEntry,
1307 cx: &mut ViewContext<V>,
1308 ) -> AnyElement<V> {
1309 let show_editor = details.is_editing && !details.is_processing;
1310
1311 let mut filename_text_style = style.text.clone();
1312 filename_text_style.color = details
1313 .git_status
1314 .as_ref()
1315 .map(|status| match status {
1316 GitFileStatus::Added => style.status.git.inserted,
1317 GitFileStatus::Modified => style.status.git.modified,
1318 GitFileStatus::Conflict => style.status.git.conflict,
1319 })
1320 .unwrap_or(style.text.color);
1321
1322 Flex::row()
1323 .with_child(if let Some(icon) = &details.icon {
1324 Svg::new(icon.to_string())
1325 .with_color(style.icon_color)
1326 .constrained()
1327 .with_max_width(style.icon_size)
1328 .with_max_height(style.icon_size)
1329 .aligned()
1330 .constrained()
1331 .with_width(style.icon_size)
1332 } else {
1333 Empty::new()
1334 .constrained()
1335 .with_max_width(style.icon_size)
1336 .with_max_height(style.icon_size)
1337 .aligned()
1338 .constrained()
1339 .with_width(style.icon_size)
1340 })
1341 .with_child(if show_editor && editor.is_some() {
1342 ChildView::new(editor.as_ref().unwrap(), cx)
1343 .contained()
1344 .with_margin_left(style.icon_spacing)
1345 .aligned()
1346 .left()
1347 .flex(1.0, true)
1348 .into_any()
1349 } else {
1350 Label::new(details.filename.clone(), filename_text_style)
1351 .contained()
1352 .with_margin_left(style.icon_spacing)
1353 .aligned()
1354 .left()
1355 .into_any()
1356 })
1357 .constrained()
1358 .with_height(style.height)
1359 .contained()
1360 .with_style(row_container_style)
1361 .with_padding_left(padding)
1362 .into_any_named("project panel entry visual element")
1363 }
1364
1365 fn render_entry(
1366 entry_id: ProjectEntryId,
1367 details: EntryDetails,
1368 editor: &ViewHandle<Editor>,
1369 dragged_entry_destination: &mut Option<Arc<Path>>,
1370 theme: &theme::ProjectPanel,
1371 cx: &mut ViewContext<Self>,
1372 ) -> AnyElement<Self> {
1373 let kind = details.kind;
1374 let path = details.path.clone();
1375 let settings = settings::get::<ProjectPanelSettings>(cx);
1376 let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1377
1378 let entry_style = if details.is_cut {
1379 &theme.cut_entry
1380 } else if details.is_ignored {
1381 &theme.ignored_entry
1382 } else {
1383 &theme.entry
1384 };
1385
1386 let show_editor = details.is_editing && !details.is_processing;
1387
1388 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1389 let mut style = entry_style
1390 .in_state(details.is_selected)
1391 .style_for(state)
1392 .clone();
1393
1394 if cx
1395 .global::<DragAndDrop<Workspace>>()
1396 .currently_dragged::<ProjectEntryId>(cx.window_id())
1397 .is_some()
1398 && dragged_entry_destination
1399 .as_ref()
1400 .filter(|destination| details.path.starts_with(destination))
1401 .is_some()
1402 {
1403 style = entry_style.active_state().default.clone();
1404 }
1405
1406 let row_container_style = if show_editor {
1407 theme.filename_editor.container
1408 } else {
1409 style.container
1410 };
1411
1412 Self::render_entry_visual_element(
1413 &details,
1414 Some(editor),
1415 padding,
1416 row_container_style,
1417 &style,
1418 cx,
1419 )
1420 })
1421 .on_click(MouseButton::Left, move |event, this, cx| {
1422 if !show_editor {
1423 if kind.is_dir() {
1424 this.toggle_expanded(entry_id, cx);
1425 } else {
1426 if event.cmd {
1427 this.split_entry(entry_id, cx);
1428 } else if !event.cmd {
1429 this.open_entry(entry_id, event.click_count > 1, cx);
1430 }
1431 }
1432 }
1433 })
1434 .on_down(MouseButton::Right, move |event, this, cx| {
1435 this.deploy_context_menu(event.position, entry_id, cx);
1436 })
1437 .on_up(MouseButton::Left, move |_, this, cx| {
1438 if let Some((_, dragged_entry)) = cx
1439 .global::<DragAndDrop<Workspace>>()
1440 .currently_dragged::<ProjectEntryId>(cx.window_id())
1441 {
1442 this.move_entry(
1443 *dragged_entry,
1444 entry_id,
1445 matches!(details.kind, EntryKind::File(_)),
1446 cx,
1447 );
1448 }
1449 })
1450 .on_move(move |_, this, cx| {
1451 if cx
1452 .global::<DragAndDrop<Workspace>>()
1453 .currently_dragged::<ProjectEntryId>(cx.window_id())
1454 .is_some()
1455 {
1456 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1457 path.parent().map(|parent| Arc::from(parent))
1458 } else {
1459 Some(path.clone())
1460 };
1461 }
1462 })
1463 .as_draggable(entry_id, {
1464 let row_container_style = theme.dragged_entry.container;
1465
1466 move |_, cx: &mut ViewContext<Workspace>| {
1467 let theme = theme::current(cx).clone();
1468 Self::render_entry_visual_element(
1469 &details,
1470 None,
1471 padding,
1472 row_container_style,
1473 &theme.project_panel.dragged_entry,
1474 cx,
1475 )
1476 }
1477 })
1478 .with_cursor_style(CursorStyle::PointingHand)
1479 .into_any_named("project panel entry")
1480 }
1481}
1482
1483impl View for ProjectPanel {
1484 fn ui_name() -> &'static str {
1485 "ProjectPanel"
1486 }
1487
1488 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1489 enum ProjectPanel {}
1490 let theme = &theme::current(cx).project_panel;
1491 let mut container_style = theme.container;
1492 let padding = std::mem::take(&mut container_style.padding);
1493 let last_worktree_root_id = self.last_worktree_root_id;
1494
1495 let has_worktree = self.visible_entries.len() != 0;
1496
1497 if has_worktree {
1498 Stack::new()
1499 .with_child(
1500 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1501 UniformList::new(
1502 self.list.clone(),
1503 self.visible_entries
1504 .iter()
1505 .map(|(_, worktree_entries)| worktree_entries.len())
1506 .sum(),
1507 cx,
1508 move |this, range, items, cx| {
1509 let theme = theme::current(cx).clone();
1510 let mut dragged_entry_destination =
1511 this.dragged_entry_destination.clone();
1512 this.for_each_visible_entry(range, cx, |id, details, cx| {
1513 items.push(Self::render_entry(
1514 id,
1515 details,
1516 &this.filename_editor,
1517 &mut dragged_entry_destination,
1518 &theme.project_panel,
1519 cx,
1520 ));
1521 });
1522 this.dragged_entry_destination = dragged_entry_destination;
1523 },
1524 )
1525 .with_padding_top(padding.top)
1526 .with_padding_bottom(padding.bottom)
1527 .contained()
1528 .with_style(container_style)
1529 .expanded()
1530 })
1531 .on_down(MouseButton::Right, move |event, this, cx| {
1532 // When deploying the context menu anywhere below the last project entry,
1533 // act as if the user clicked the root of the last worktree.
1534 if let Some(entry_id) = last_worktree_root_id {
1535 this.deploy_context_menu(event.position, entry_id, cx);
1536 }
1537 }),
1538 )
1539 .with_child(ChildView::new(&self.context_menu, cx))
1540 .into_any_named("project panel")
1541 } else {
1542 Flex::column()
1543 .with_child(
1544 MouseEventHandler::<Self, _>::new(2, cx, {
1545 let button_style = theme.open_project_button.clone();
1546 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1547 move |state, cx| {
1548 let button_style = button_style.style_for(state).clone();
1549 let context_menu_item = context_menu_item_style
1550 .active_state()
1551 .style_for(state)
1552 .clone();
1553
1554 theme::ui::keystroke_label(
1555 "Open a project",
1556 &button_style,
1557 &context_menu_item.keystroke,
1558 Box::new(workspace::Open),
1559 cx,
1560 )
1561 }
1562 })
1563 .on_click(MouseButton::Left, move |_, this, cx| {
1564 if let Some(workspace) = this.workspace.upgrade(cx) {
1565 workspace.update(cx, |workspace, cx| {
1566 if let Some(task) = workspace.open(&Default::default(), cx) {
1567 task.detach_and_log_err(cx);
1568 }
1569 })
1570 }
1571 })
1572 .with_cursor_style(CursorStyle::PointingHand),
1573 )
1574 .contained()
1575 .with_style(container_style)
1576 .into_any_named("empty project panel")
1577 }
1578 }
1579
1580 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1581 Self::reset_to_default_keymap_context(keymap);
1582 keymap.add_identifier("menu");
1583 }
1584
1585 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1586 if !self.has_focus {
1587 self.has_focus = true;
1588 cx.emit(Event::Focus);
1589 }
1590 }
1591
1592 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1593 self.has_focus = false;
1594 }
1595}
1596
1597impl Entity for ProjectPanel {
1598 type Event = Event;
1599}
1600
1601impl workspace::dock::Panel for ProjectPanel {
1602 fn position(&self, cx: &WindowContext) -> DockPosition {
1603 match settings::get::<ProjectPanelSettings>(cx).dock {
1604 ProjectPanelDockPosition::Left => DockPosition::Left,
1605 ProjectPanelDockPosition::Right => DockPosition::Right,
1606 }
1607 }
1608
1609 fn position_is_valid(&self, position: DockPosition) -> bool {
1610 matches!(position, DockPosition::Left | DockPosition::Right)
1611 }
1612
1613 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1614 settings::update_settings_file::<ProjectPanelSettings>(
1615 self.fs.clone(),
1616 cx,
1617 move |settings| {
1618 let dock = match position {
1619 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1620 DockPosition::Right => ProjectPanelDockPosition::Right,
1621 };
1622 settings.dock = Some(dock);
1623 },
1624 );
1625 }
1626
1627 fn size(&self, cx: &WindowContext) -> f32 {
1628 self.width
1629 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1630 }
1631
1632 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1633 self.width = Some(size);
1634 self.serialize(cx);
1635 cx.notify();
1636 }
1637
1638 fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1639 false
1640 }
1641
1642 fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1643 false
1644 }
1645
1646 fn is_zoomed(&self, _: &WindowContext) -> bool {
1647 false
1648 }
1649
1650 fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1651
1652 fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1653
1654 fn icon_path(&self) -> &'static str {
1655 "icons/folder_tree_16.svg"
1656 }
1657
1658 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1659 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1660 }
1661
1662 fn should_change_position_on_event(event: &Self::Event) -> bool {
1663 matches!(event, Event::DockPositionChanged)
1664 }
1665
1666 fn should_activate_on_event(_: &Self::Event) -> bool {
1667 false
1668 }
1669
1670 fn should_close_on_event(_: &Self::Event) -> bool {
1671 false
1672 }
1673
1674 fn has_focus(&self, _: &WindowContext) -> bool {
1675 self.has_focus
1676 }
1677
1678 fn is_focus_event(event: &Self::Event) -> bool {
1679 matches!(event, Event::Focus)
1680 }
1681}
1682
1683impl ClipboardEntry {
1684 fn is_cut(&self) -> bool {
1685 matches!(self, Self::Cut { .. })
1686 }
1687
1688 fn entry_id(&self) -> ProjectEntryId {
1689 match self {
1690 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1691 *entry_id
1692 }
1693 }
1694 }
1695
1696 fn worktree_id(&self) -> WorktreeId {
1697 match self {
1698 ClipboardEntry::Copied { worktree_id, .. }
1699 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1700 }
1701 }
1702}
1703
1704#[cfg(test)]
1705mod tests {
1706 use super::*;
1707 use gpui::{TestAppContext, ViewHandle};
1708 use pretty_assertions::assert_eq;
1709 use project::FakeFs;
1710 use serde_json::json;
1711 use settings::SettingsStore;
1712 use std::{
1713 collections::HashSet,
1714 path::Path,
1715 sync::atomic::{self, AtomicUsize},
1716 };
1717 use workspace::{pane, AppState};
1718
1719 #[gpui::test]
1720 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1721 init_test(cx);
1722
1723 let fs = FakeFs::new(cx.background());
1724 fs.insert_tree(
1725 "/root1",
1726 json!({
1727 ".dockerignore": "",
1728 ".git": {
1729 "HEAD": "",
1730 },
1731 "a": {
1732 "0": { "q": "", "r": "", "s": "" },
1733 "1": { "t": "", "u": "" },
1734 "2": { "v": "", "w": "", "x": "", "y": "" },
1735 },
1736 "b": {
1737 "3": { "Q": "" },
1738 "4": { "R": "", "S": "", "T": "", "U": "" },
1739 },
1740 "C": {
1741 "5": {},
1742 "6": { "V": "", "W": "" },
1743 "7": { "X": "" },
1744 "8": { "Y": {}, "Z": "" }
1745 }
1746 }),
1747 )
1748 .await;
1749 fs.insert_tree(
1750 "/root2",
1751 json!({
1752 "d": {
1753 "9": ""
1754 },
1755 "e": {}
1756 }),
1757 )
1758 .await;
1759
1760 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1761 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1762 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1763 assert_eq!(
1764 visible_entries_as_strings(&panel, 0..50, cx),
1765 &[
1766 "v root1",
1767 " > .git",
1768 " > a",
1769 " > b",
1770 " > C",
1771 " .dockerignore",
1772 "v root2",
1773 " > d",
1774 " > e",
1775 ]
1776 );
1777
1778 toggle_expand_dir(&panel, "root1/b", cx);
1779 assert_eq!(
1780 visible_entries_as_strings(&panel, 0..50, cx),
1781 &[
1782 "v root1",
1783 " > .git",
1784 " > a",
1785 " v b <== selected",
1786 " > 3",
1787 " > 4",
1788 " > C",
1789 " .dockerignore",
1790 "v root2",
1791 " > d",
1792 " > e",
1793 ]
1794 );
1795
1796 assert_eq!(
1797 visible_entries_as_strings(&panel, 6..9, cx),
1798 &[
1799 //
1800 " > C",
1801 " .dockerignore",
1802 "v root2",
1803 ]
1804 );
1805 }
1806
1807 #[gpui::test(iterations = 30)]
1808 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1809 init_test(cx);
1810
1811 let fs = FakeFs::new(cx.background());
1812 fs.insert_tree(
1813 "/root1",
1814 json!({
1815 ".dockerignore": "",
1816 ".git": {
1817 "HEAD": "",
1818 },
1819 "a": {
1820 "0": { "q": "", "r": "", "s": "" },
1821 "1": { "t": "", "u": "" },
1822 "2": { "v": "", "w": "", "x": "", "y": "" },
1823 },
1824 "b": {
1825 "3": { "Q": "" },
1826 "4": { "R": "", "S": "", "T": "", "U": "" },
1827 },
1828 "C": {
1829 "5": {},
1830 "6": { "V": "", "W": "" },
1831 "7": { "X": "" },
1832 "8": { "Y": {}, "Z": "" }
1833 }
1834 }),
1835 )
1836 .await;
1837 fs.insert_tree(
1838 "/root2",
1839 json!({
1840 "d": {
1841 "9": ""
1842 },
1843 "e": {}
1844 }),
1845 )
1846 .await;
1847
1848 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1849 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1850 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1851
1852 select_path(&panel, "root1", cx);
1853 assert_eq!(
1854 visible_entries_as_strings(&panel, 0..10, cx),
1855 &[
1856 "v root1 <== selected",
1857 " > .git",
1858 " > a",
1859 " > b",
1860 " > C",
1861 " .dockerignore",
1862 "v root2",
1863 " > d",
1864 " > e",
1865 ]
1866 );
1867
1868 // Add a file with the root folder selected. The filename editor is placed
1869 // before the first file in the root folder.
1870 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1871 cx.read_window(window_id, |cx| {
1872 let panel = panel.read(cx);
1873 assert!(panel.filename_editor.is_focused(cx));
1874 });
1875 assert_eq!(
1876 visible_entries_as_strings(&panel, 0..10, cx),
1877 &[
1878 "v root1",
1879 " > .git",
1880 " > a",
1881 " > b",
1882 " > C",
1883 " [EDITOR: ''] <== selected",
1884 " .dockerignore",
1885 "v root2",
1886 " > d",
1887 " > e",
1888 ]
1889 );
1890
1891 let confirm = panel.update(cx, |panel, cx| {
1892 panel
1893 .filename_editor
1894 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1895 panel.confirm(&Confirm, cx).unwrap()
1896 });
1897 assert_eq!(
1898 visible_entries_as_strings(&panel, 0..10, cx),
1899 &[
1900 "v root1",
1901 " > .git",
1902 " > a",
1903 " > b",
1904 " > C",
1905 " [PROCESSING: 'the-new-filename'] <== selected",
1906 " .dockerignore",
1907 "v root2",
1908 " > d",
1909 " > e",
1910 ]
1911 );
1912
1913 confirm.await.unwrap();
1914 assert_eq!(
1915 visible_entries_as_strings(&panel, 0..10, cx),
1916 &[
1917 "v root1",
1918 " > .git",
1919 " > a",
1920 " > b",
1921 " > C",
1922 " .dockerignore",
1923 " the-new-filename <== selected",
1924 "v root2",
1925 " > d",
1926 " > e",
1927 ]
1928 );
1929
1930 select_path(&panel, "root1/b", cx);
1931 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1932 assert_eq!(
1933 visible_entries_as_strings(&panel, 0..10, cx),
1934 &[
1935 "v root1",
1936 " > .git",
1937 " > a",
1938 " v b",
1939 " > 3",
1940 " > 4",
1941 " [EDITOR: ''] <== selected",
1942 " > C",
1943 " .dockerignore",
1944 " the-new-filename",
1945 ]
1946 );
1947
1948 panel
1949 .update(cx, |panel, cx| {
1950 panel
1951 .filename_editor
1952 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1953 panel.confirm(&Confirm, cx).unwrap()
1954 })
1955 .await
1956 .unwrap();
1957 assert_eq!(
1958 visible_entries_as_strings(&panel, 0..10, cx),
1959 &[
1960 "v root1",
1961 " > .git",
1962 " > a",
1963 " v b",
1964 " > 3",
1965 " > 4",
1966 " another-filename.txt <== selected",
1967 " > C",
1968 " .dockerignore",
1969 " the-new-filename",
1970 ]
1971 );
1972
1973 select_path(&panel, "root1/b/another-filename.txt", cx);
1974 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1975 assert_eq!(
1976 visible_entries_as_strings(&panel, 0..10, cx),
1977 &[
1978 "v root1",
1979 " > .git",
1980 " > a",
1981 " v b",
1982 " > 3",
1983 " > 4",
1984 " [EDITOR: 'another-filename.txt'] <== selected",
1985 " > C",
1986 " .dockerignore",
1987 " the-new-filename",
1988 ]
1989 );
1990
1991 let confirm = panel.update(cx, |panel, cx| {
1992 panel.filename_editor.update(cx, |editor, cx| {
1993 let file_name_selections = editor.selections.all::<usize>(cx);
1994 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1995 let file_name_selection = &file_name_selections[0];
1996 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1997 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1998
1999 editor.set_text("a-different-filename.tar.gz", cx)
2000 });
2001 panel.confirm(&Confirm, cx).unwrap()
2002 });
2003 assert_eq!(
2004 visible_entries_as_strings(&panel, 0..10, cx),
2005 &[
2006 "v root1",
2007 " > .git",
2008 " > a",
2009 " v b",
2010 " > 3",
2011 " > 4",
2012 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2013 " > C",
2014 " .dockerignore",
2015 " the-new-filename",
2016 ]
2017 );
2018
2019 confirm.await.unwrap();
2020 assert_eq!(
2021 visible_entries_as_strings(&panel, 0..10, cx),
2022 &[
2023 "v root1",
2024 " > .git",
2025 " > a",
2026 " v b",
2027 " > 3",
2028 " > 4",
2029 " a-different-filename.tar.gz <== selected",
2030 " > C",
2031 " .dockerignore",
2032 " the-new-filename",
2033 ]
2034 );
2035
2036 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2037 assert_eq!(
2038 visible_entries_as_strings(&panel, 0..10, cx),
2039 &[
2040 "v root1",
2041 " > .git",
2042 " > a",
2043 " v b",
2044 " > 3",
2045 " > 4",
2046 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2047 " > C",
2048 " .dockerignore",
2049 " the-new-filename",
2050 ]
2051 );
2052
2053 panel.update(cx, |panel, cx| {
2054 panel.filename_editor.update(cx, |editor, cx| {
2055 let file_name_selections = editor.selections.all::<usize>(cx);
2056 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2057 let file_name_selection = &file_name_selections[0];
2058 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2059 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");
2060
2061 });
2062 panel.cancel(&Cancel, cx)
2063 });
2064
2065 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2066 assert_eq!(
2067 visible_entries_as_strings(&panel, 0..10, cx),
2068 &[
2069 "v root1",
2070 " > .git",
2071 " > a",
2072 " v b",
2073 " > [EDITOR: ''] <== selected",
2074 " > 3",
2075 " > 4",
2076 " a-different-filename.tar.gz",
2077 " > C",
2078 " .dockerignore",
2079 ]
2080 );
2081
2082 let confirm = panel.update(cx, |panel, cx| {
2083 panel
2084 .filename_editor
2085 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2086 panel.confirm(&Confirm, cx).unwrap()
2087 });
2088 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2089 assert_eq!(
2090 visible_entries_as_strings(&panel, 0..10, cx),
2091 &[
2092 "v root1",
2093 " > .git",
2094 " > a",
2095 " v b",
2096 " > [PROCESSING: 'new-dir']",
2097 " > 3 <== selected",
2098 " > 4",
2099 " a-different-filename.tar.gz",
2100 " > C",
2101 " .dockerignore",
2102 ]
2103 );
2104
2105 confirm.await.unwrap();
2106 assert_eq!(
2107 visible_entries_as_strings(&panel, 0..10, cx),
2108 &[
2109 "v root1",
2110 " > .git",
2111 " > a",
2112 " v b",
2113 " > 3 <== selected",
2114 " > 4",
2115 " > new-dir",
2116 " a-different-filename.tar.gz",
2117 " > C",
2118 " .dockerignore",
2119 ]
2120 );
2121
2122 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2123 assert_eq!(
2124 visible_entries_as_strings(&panel, 0..10, cx),
2125 &[
2126 "v root1",
2127 " > .git",
2128 " > a",
2129 " v b",
2130 " > [EDITOR: '3'] <== selected",
2131 " > 4",
2132 " > new-dir",
2133 " a-different-filename.tar.gz",
2134 " > C",
2135 " .dockerignore",
2136 ]
2137 );
2138
2139 // Dismiss the rename editor when it loses focus.
2140 workspace.update(cx, |_, cx| cx.focus_self());
2141 assert_eq!(
2142 visible_entries_as_strings(&panel, 0..10, cx),
2143 &[
2144 "v root1",
2145 " > .git",
2146 " > a",
2147 " v b",
2148 " > 3 <== selected",
2149 " > 4",
2150 " > new-dir",
2151 " a-different-filename.tar.gz",
2152 " > C",
2153 " .dockerignore",
2154 ]
2155 );
2156 }
2157
2158 #[gpui::test(iterations = 30)]
2159 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2160 init_test(cx);
2161
2162 let fs = FakeFs::new(cx.background());
2163 fs.insert_tree(
2164 "/root1",
2165 json!({
2166 ".dockerignore": "",
2167 ".git": {
2168 "HEAD": "",
2169 },
2170 "a": {
2171 "0": { "q": "", "r": "", "s": "" },
2172 "1": { "t": "", "u": "" },
2173 "2": { "v": "", "w": "", "x": "", "y": "" },
2174 },
2175 "b": {
2176 "3": { "Q": "" },
2177 "4": { "R": "", "S": "", "T": "", "U": "" },
2178 },
2179 "C": {
2180 "5": {},
2181 "6": { "V": "", "W": "" },
2182 "7": { "X": "" },
2183 "8": { "Y": {}, "Z": "" }
2184 }
2185 }),
2186 )
2187 .await;
2188 fs.insert_tree(
2189 "/root2",
2190 json!({
2191 "d": {
2192 "9": ""
2193 },
2194 "e": {}
2195 }),
2196 )
2197 .await;
2198
2199 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2200 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2201 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2202
2203 select_path(&panel, "root1", cx);
2204 assert_eq!(
2205 visible_entries_as_strings(&panel, 0..10, cx),
2206 &[
2207 "v root1 <== selected",
2208 " > .git",
2209 " > a",
2210 " > b",
2211 " > C",
2212 " .dockerignore",
2213 "v root2",
2214 " > d",
2215 " > e",
2216 ]
2217 );
2218
2219 // Add a file with the root folder selected. The filename editor is placed
2220 // before the first file in the root folder.
2221 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2222 cx.read_window(window_id, |cx| {
2223 let panel = panel.read(cx);
2224 assert!(panel.filename_editor.is_focused(cx));
2225 });
2226 assert_eq!(
2227 visible_entries_as_strings(&panel, 0..10, cx),
2228 &[
2229 "v root1",
2230 " > .git",
2231 " > a",
2232 " > b",
2233 " > C",
2234 " [EDITOR: ''] <== selected",
2235 " .dockerignore",
2236 "v root2",
2237 " > d",
2238 " > e",
2239 ]
2240 );
2241
2242 let confirm = panel.update(cx, |panel, cx| {
2243 panel.filename_editor.update(cx, |editor, cx| {
2244 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2245 });
2246 panel.confirm(&Confirm, cx).unwrap()
2247 });
2248
2249 assert_eq!(
2250 visible_entries_as_strings(&panel, 0..10, cx),
2251 &[
2252 "v root1",
2253 " > .git",
2254 " > a",
2255 " > b",
2256 " > C",
2257 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2258 " .dockerignore",
2259 "v root2",
2260 " > d",
2261 " > e",
2262 ]
2263 );
2264
2265 confirm.await.unwrap();
2266 assert_eq!(
2267 visible_entries_as_strings(&panel, 0..13, cx),
2268 &[
2269 "v root1",
2270 " > .git",
2271 " > a",
2272 " > b",
2273 " v bdir1",
2274 " v dir2",
2275 " the-new-filename <== selected",
2276 " > C",
2277 " .dockerignore",
2278 "v root2",
2279 " > d",
2280 " > e",
2281 ]
2282 );
2283 }
2284
2285 #[gpui::test]
2286 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2287 init_test(cx);
2288
2289 let fs = FakeFs::new(cx.background());
2290 fs.insert_tree(
2291 "/root1",
2292 json!({
2293 "one.two.txt": "",
2294 "one.txt": ""
2295 }),
2296 )
2297 .await;
2298
2299 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2300 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2301 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2302
2303 panel.update(cx, |panel, cx| {
2304 panel.select_next(&Default::default(), cx);
2305 panel.select_next(&Default::default(), cx);
2306 });
2307
2308 assert_eq!(
2309 visible_entries_as_strings(&panel, 0..50, cx),
2310 &[
2311 //
2312 "v root1",
2313 " one.two.txt <== selected",
2314 " one.txt",
2315 ]
2316 );
2317
2318 // Regression test - file name is created correctly when
2319 // the copied file's name contains multiple dots.
2320 panel.update(cx, |panel, cx| {
2321 panel.copy(&Default::default(), cx);
2322 panel.paste(&Default::default(), cx);
2323 });
2324 cx.foreground().run_until_parked();
2325
2326 assert_eq!(
2327 visible_entries_as_strings(&panel, 0..50, cx),
2328 &[
2329 //
2330 "v root1",
2331 " one.two copy.txt",
2332 " one.two.txt <== selected",
2333 " one.txt",
2334 ]
2335 );
2336
2337 panel.update(cx, |panel, cx| {
2338 panel.paste(&Default::default(), cx);
2339 });
2340 cx.foreground().run_until_parked();
2341
2342 assert_eq!(
2343 visible_entries_as_strings(&panel, 0..50, cx),
2344 &[
2345 //
2346 "v root1",
2347 " one.two copy 1.txt",
2348 " one.two copy.txt",
2349 " one.two.txt <== selected",
2350 " one.txt",
2351 ]
2352 );
2353 }
2354
2355 #[gpui::test]
2356 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2357 init_test_with_editor(cx);
2358
2359 let fs = FakeFs::new(cx.background());
2360 fs.insert_tree(
2361 "/src",
2362 json!({
2363 "test": {
2364 "first.rs": "// First Rust file",
2365 "second.rs": "// Second Rust file",
2366 "third.rs": "// Third Rust file",
2367 }
2368 }),
2369 )
2370 .await;
2371
2372 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2373 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2374 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2375
2376 toggle_expand_dir(&panel, "src/test", cx);
2377 select_path(&panel, "src/test/first.rs", cx);
2378 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2379 cx.foreground().run_until_parked();
2380 assert_eq!(
2381 visible_entries_as_strings(&panel, 0..10, cx),
2382 &[
2383 "v src",
2384 " v test",
2385 " first.rs <== selected",
2386 " second.rs",
2387 " third.rs"
2388 ]
2389 );
2390 ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2391
2392 submit_deletion(window_id, &panel, cx);
2393 assert_eq!(
2394 visible_entries_as_strings(&panel, 0..10, cx),
2395 &[
2396 "v src",
2397 " v test",
2398 " second.rs",
2399 " third.rs"
2400 ],
2401 "Project panel should have no deleted file, no other file is selected in it"
2402 );
2403 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2404
2405 select_path(&panel, "src/test/second.rs", cx);
2406 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2407 cx.foreground().run_until_parked();
2408 assert_eq!(
2409 visible_entries_as_strings(&panel, 0..10, cx),
2410 &[
2411 "v src",
2412 " v test",
2413 " second.rs <== selected",
2414 " third.rs"
2415 ]
2416 );
2417 ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2418
2419 cx.update_window(window_id, |cx| {
2420 let active_items = workspace
2421 .read(cx)
2422 .panes()
2423 .iter()
2424 .filter_map(|pane| pane.read(cx).active_item())
2425 .collect::<Vec<_>>();
2426 assert_eq!(active_items.len(), 1);
2427 let open_editor = active_items
2428 .into_iter()
2429 .next()
2430 .unwrap()
2431 .downcast::<Editor>()
2432 .expect("Open item should be an editor");
2433 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2434 });
2435 submit_deletion(window_id, &panel, cx);
2436 assert_eq!(
2437 visible_entries_as_strings(&panel, 0..10, cx),
2438 &["v src", " v test", " third.rs"],
2439 "Project panel should have no deleted file, with one last file remaining"
2440 );
2441 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2442 }
2443
2444 #[gpui::test]
2445 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2446 init_test_with_editor(cx);
2447
2448 let fs = FakeFs::new(cx.background());
2449 fs.insert_tree(
2450 "/src",
2451 json!({
2452 "test": {
2453 "first.rs": "// First Rust file",
2454 "second.rs": "// Second Rust file",
2455 "third.rs": "// Third Rust file",
2456 }
2457 }),
2458 )
2459 .await;
2460
2461 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2462 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2463 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2464
2465 select_path(&panel, "src/", cx);
2466 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2467 cx.foreground().run_until_parked();
2468 assert_eq!(
2469 visible_entries_as_strings(&panel, 0..10, cx),
2470 &["v src <== selected", " > test"]
2471 );
2472 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2473 cx.read_window(window_id, |cx| {
2474 let panel = panel.read(cx);
2475 assert!(panel.filename_editor.is_focused(cx));
2476 });
2477 assert_eq!(
2478 visible_entries_as_strings(&panel, 0..10, cx),
2479 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2480 );
2481 panel.update(cx, |panel, cx| {
2482 panel
2483 .filename_editor
2484 .update(cx, |editor, cx| editor.set_text("test", cx));
2485 assert!(
2486 panel.confirm(&Confirm, cx).is_none(),
2487 "Should not allow to confirm on conflicting new directory name"
2488 )
2489 });
2490 assert_eq!(
2491 visible_entries_as_strings(&panel, 0..10, cx),
2492 &["v src", " > test"],
2493 "File list should be unchanged after failed folder create confirmation"
2494 );
2495
2496 select_path(&panel, "src/test/", cx);
2497 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2498 cx.foreground().run_until_parked();
2499 assert_eq!(
2500 visible_entries_as_strings(&panel, 0..10, cx),
2501 &["v src", " > test <== selected"]
2502 );
2503 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2504 cx.read_window(window_id, |cx| {
2505 let panel = panel.read(cx);
2506 assert!(panel.filename_editor.is_focused(cx));
2507 });
2508 assert_eq!(
2509 visible_entries_as_strings(&panel, 0..10, cx),
2510 &[
2511 "v src",
2512 " v test",
2513 " [EDITOR: ''] <== selected",
2514 " first.rs",
2515 " second.rs",
2516 " third.rs"
2517 ]
2518 );
2519 panel.update(cx, |panel, cx| {
2520 panel
2521 .filename_editor
2522 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2523 assert!(
2524 panel.confirm(&Confirm, cx).is_none(),
2525 "Should not allow to confirm on conflicting new file name"
2526 )
2527 });
2528 assert_eq!(
2529 visible_entries_as_strings(&panel, 0..10, cx),
2530 &[
2531 "v src",
2532 " v test",
2533 " first.rs",
2534 " second.rs",
2535 " third.rs"
2536 ],
2537 "File list should be unchanged after failed file create confirmation"
2538 );
2539
2540 select_path(&panel, "src/test/first.rs", cx);
2541 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2542 cx.foreground().run_until_parked();
2543 assert_eq!(
2544 visible_entries_as_strings(&panel, 0..10, cx),
2545 &[
2546 "v src",
2547 " v test",
2548 " first.rs <== selected",
2549 " second.rs",
2550 " third.rs"
2551 ],
2552 );
2553 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2554 cx.read_window(window_id, |cx| {
2555 let panel = panel.read(cx);
2556 assert!(panel.filename_editor.is_focused(cx));
2557 });
2558 assert_eq!(
2559 visible_entries_as_strings(&panel, 0..10, cx),
2560 &[
2561 "v src",
2562 " v test",
2563 " [EDITOR: 'first.rs'] <== selected",
2564 " second.rs",
2565 " third.rs"
2566 ]
2567 );
2568 panel.update(cx, |panel, cx| {
2569 panel
2570 .filename_editor
2571 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2572 assert!(
2573 panel.confirm(&Confirm, cx).is_none(),
2574 "Should not allow to confirm on conflicting file rename"
2575 )
2576 });
2577 assert_eq!(
2578 visible_entries_as_strings(&panel, 0..10, cx),
2579 &[
2580 "v src",
2581 " v test",
2582 " first.rs <== selected",
2583 " second.rs",
2584 " third.rs"
2585 ],
2586 "File list should be unchanged after failed rename confirmation"
2587 );
2588 }
2589
2590 #[gpui::test]
2591 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2592 init_test_with_editor(cx);
2593
2594 let fs = FakeFs::new(cx.background());
2595 fs.insert_tree(
2596 "/src",
2597 json!({
2598 "test": {
2599 "first.rs": "// First Rust file",
2600 "second.rs": "// Second Rust file",
2601 "third.rs": "// Third Rust file",
2602 }
2603 }),
2604 )
2605 .await;
2606
2607 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2608 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2609 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2610
2611 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2612 let _subscription = panel.update(cx, |_, cx| {
2613 let subcription_count = Arc::clone(&new_search_events_count);
2614 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2615 if matches!(event, Event::NewSearchInDirectory { .. }) {
2616 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2617 }
2618 })
2619 });
2620
2621 toggle_expand_dir(&panel, "src/test", cx);
2622 select_path(&panel, "src/test/first.rs", cx);
2623 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2624 cx.foreground().run_until_parked();
2625 assert_eq!(
2626 visible_entries_as_strings(&panel, 0..10, cx),
2627 &[
2628 "v src",
2629 " v test",
2630 " first.rs <== selected",
2631 " second.rs",
2632 " third.rs"
2633 ]
2634 );
2635 panel.update(cx, |panel, cx| {
2636 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2637 });
2638 assert_eq!(
2639 new_search_events_count.load(atomic::Ordering::SeqCst),
2640 0,
2641 "Should not trigger new search in directory when called on a file"
2642 );
2643
2644 select_path(&panel, "src/test", cx);
2645 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2646 cx.foreground().run_until_parked();
2647 assert_eq!(
2648 visible_entries_as_strings(&panel, 0..10, cx),
2649 &[
2650 "v src",
2651 " v test <== selected",
2652 " first.rs",
2653 " second.rs",
2654 " third.rs"
2655 ]
2656 );
2657 panel.update(cx, |panel, cx| {
2658 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2659 });
2660 assert_eq!(
2661 new_search_events_count.load(atomic::Ordering::SeqCst),
2662 1,
2663 "Should trigger new search in directory when called on a directory"
2664 );
2665 }
2666
2667 fn toggle_expand_dir(
2668 panel: &ViewHandle<ProjectPanel>,
2669 path: impl AsRef<Path>,
2670 cx: &mut TestAppContext,
2671 ) {
2672 let path = path.as_ref();
2673 panel.update(cx, |panel, cx| {
2674 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2675 let worktree = worktree.read(cx);
2676 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2677 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2678 panel.toggle_expanded(entry_id, cx);
2679 return;
2680 }
2681 }
2682 panic!("no worktree for path {:?}", path);
2683 });
2684 }
2685
2686 fn select_path(
2687 panel: &ViewHandle<ProjectPanel>,
2688 path: impl AsRef<Path>,
2689 cx: &mut TestAppContext,
2690 ) {
2691 let path = path.as_ref();
2692 panel.update(cx, |panel, cx| {
2693 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2694 let worktree = worktree.read(cx);
2695 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2696 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2697 panel.selection = Some(Selection {
2698 worktree_id: worktree.id(),
2699 entry_id,
2700 });
2701 return;
2702 }
2703 }
2704 panic!("no worktree for path {:?}", path);
2705 });
2706 }
2707
2708 fn visible_entries_as_strings(
2709 panel: &ViewHandle<ProjectPanel>,
2710 range: Range<usize>,
2711 cx: &mut TestAppContext,
2712 ) -> Vec<String> {
2713 let mut result = Vec::new();
2714 let mut project_entries = HashSet::new();
2715 let mut has_editor = false;
2716
2717 panel.update(cx, |panel, cx| {
2718 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2719 if details.is_editing {
2720 assert!(!has_editor, "duplicate editor entry");
2721 has_editor = true;
2722 } else {
2723 assert!(
2724 project_entries.insert(project_entry),
2725 "duplicate project entry {:?} {:?}",
2726 project_entry,
2727 details
2728 );
2729 }
2730
2731 let indent = " ".repeat(details.depth);
2732 let icon = if details.kind.is_dir() {
2733 if details.is_expanded {
2734 "v "
2735 } else {
2736 "> "
2737 }
2738 } else {
2739 " "
2740 };
2741 let name = if details.is_editing {
2742 format!("[EDITOR: '{}']", details.filename)
2743 } else if details.is_processing {
2744 format!("[PROCESSING: '{}']", details.filename)
2745 } else {
2746 details.filename.clone()
2747 };
2748 let selected = if details.is_selected {
2749 " <== selected"
2750 } else {
2751 ""
2752 };
2753 result.push(format!("{indent}{icon}{name}{selected}"));
2754 });
2755 });
2756
2757 result
2758 }
2759
2760 fn init_test(cx: &mut TestAppContext) {
2761 cx.foreground().forbid_parking();
2762 cx.update(|cx| {
2763 cx.set_global(SettingsStore::test(cx));
2764 init_settings(cx);
2765 theme::init((), cx);
2766 language::init(cx);
2767 editor::init_settings(cx);
2768 crate::init((), cx);
2769 workspace::init_settings(cx);
2770 Project::init_settings(cx);
2771 });
2772 }
2773
2774 fn init_test_with_editor(cx: &mut TestAppContext) {
2775 cx.foreground().forbid_parking();
2776 cx.update(|cx| {
2777 let app_state = AppState::test(cx);
2778 theme::init((), cx);
2779 init_settings(cx);
2780 language::init(cx);
2781 editor::init(cx);
2782 pane::init(cx);
2783 crate::init((), cx);
2784 workspace::init(app_state.clone(), cx);
2785 Project::init_settings(cx);
2786 });
2787 }
2788
2789 fn ensure_single_file_is_opened(
2790 window_id: usize,
2791 workspace: &ViewHandle<Workspace>,
2792 expected_path: &str,
2793 cx: &mut TestAppContext,
2794 ) {
2795 cx.read_window(window_id, |cx| {
2796 let workspace = workspace.read(cx);
2797 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2798 assert_eq!(worktrees.len(), 1);
2799 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2800
2801 let open_project_paths = workspace
2802 .panes()
2803 .iter()
2804 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2805 .collect::<Vec<_>>();
2806 assert_eq!(
2807 open_project_paths,
2808 vec![ProjectPath {
2809 worktree_id,
2810 path: Arc::from(Path::new(expected_path))
2811 }],
2812 "Should have opened file, selected in project panel"
2813 );
2814 });
2815 }
2816
2817 fn submit_deletion(
2818 window_id: usize,
2819 panel: &ViewHandle<ProjectPanel>,
2820 cx: &mut TestAppContext,
2821 ) {
2822 assert!(
2823 !cx.has_pending_prompt(window_id),
2824 "Should have no prompts before the deletion"
2825 );
2826 panel.update(cx, |panel, cx| {
2827 panel
2828 .delete(&Delete, cx)
2829 .expect("Deletion start")
2830 .detach_and_log_err(cx);
2831 });
2832 assert!(
2833 cx.has_pending_prompt(window_id),
2834 "Should have a prompt after the deletion"
2835 );
2836 cx.simulate_prompt_answer(window_id, 0);
2837 assert!(
2838 !cx.has_pending_prompt(window_id),
2839 "Should have no prompts after prompt was replied to"
2840 );
2841 cx.foreground().run_until_parked();
2842 }
2843
2844 fn ensure_no_open_items_and_panes(
2845 window_id: usize,
2846 workspace: &ViewHandle<Workspace>,
2847 cx: &mut TestAppContext,
2848 ) {
2849 assert!(
2850 !cx.has_pending_prompt(window_id),
2851 "Should have no prompts after deletion operation closes the file"
2852 );
2853 cx.read_window(window_id, |cx| {
2854 let open_project_paths = workspace
2855 .read(cx)
2856 .panes()
2857 .iter()
2858 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2859 .collect::<Vec<_>>();
2860 assert!(
2861 open_project_paths.is_empty(),
2862 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2863 );
2864 });
2865 }
2866}