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