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