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