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