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