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::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
197 this.autoscroll(cx);
198 }
199 editor::Event::Blurred => {
200 if this
201 .edit_state
202 .as_ref()
203 .map_or(false, |state| state.processing_filename.is_none())
204 {
205 this.edit_state = None;
206 this.update_visible_entries(None, cx);
207 }
208 }
209 _ => {}
210 })
211 .detach();
212
213 // cx.observe_global::<FileAssociations, _>(|_, cx| {
214 // cx.notify();
215 // })
216 // .detach();
217
218 let mut this = Self {
219 project: project.clone(),
220 fs: workspace.app_state().fs.clone(),
221 list: UniformListScrollHandle::new(),
222 focus_handle,
223 visible_entries: Default::default(),
224 last_worktree_root_id: Default::default(),
225 expanded_dir_ids: Default::default(),
226 selection: None,
227 edit_state: None,
228 filename_editor,
229 clipboard_entry: None,
230 // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
231 _dragged_entry_destination: None,
232 _workspace: workspace.weak_handle(),
233 has_focus: false,
234 width: None,
235 pending_serialization: Task::ready(None),
236 };
237 this.update_visible_entries(None, cx);
238
239 // Update the dock position when the setting changes.
240 // todo!()
241 // let mut old_dock_position = this.position(cx);
242 // cx.observe_global::<SettingsStore, _>(move |this, cx| {
243 // let new_dock_position = this.position(cx);
244 // if new_dock_position != old_dock_position {
245 // old_dock_position = new_dock_position;
246 // cx.emit(Event::DockPositionChanged);
247 // }
248 // })
249 // .detach();
250
251 this
252 });
253
254 cx.subscribe(&project_panel, {
255 let project_panel = project_panel.downgrade();
256 move |workspace, _, event, cx| match event {
257 &Event::OpenedEntry {
258 entry_id,
259 focus_opened_item,
260 } => {
261 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
262 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
263 workspace
264 .open_path(
265 ProjectPath {
266 worktree_id: worktree.read(cx).id(),
267 path: entry.path.clone(),
268 },
269 None,
270 focus_opened_item,
271 cx,
272 )
273 .detach_and_log_err(cx);
274 if !focus_opened_item {
275 if let Some(project_panel) = project_panel.upgrade() {
276 let focus_handle = project_panel.read(cx).focus_handle.clone();
277 cx.focus(&focus_handle);
278 }
279 }
280 }
281 }
282 }
283 &Event::SplitEntry { entry_id } => {
284 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
285 if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
286 // workspace
287 // .split_path(
288 // ProjectPath {
289 // worktree_id: worktree.read(cx).id(),
290 // path: entry.path.clone(),
291 // },
292 // cx,
293 // )
294 // .detach_and_log_err(cx);
295 }
296 }
297 }
298 _ => {}
299 }
300 })
301 .detach();
302
303 project_panel
304 }
305
306 pub fn load(
307 workspace: WeakView<Workspace>,
308 cx: AsyncWindowContext,
309 ) -> Task<Result<View<Self>>> {
310 cx.spawn(|mut cx| async move {
311 // let serialized_panel = if let Some(panel) = cx
312 // .background_executor()
313 // .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
314 // .await
315 // .log_err()
316 // .flatten()
317 // {
318 // Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
319 // } else {
320 // None
321 // };
322 workspace.update(&mut cx, |workspace, cx| {
323 let panel = ProjectPanel::new(workspace, cx);
324 // if let Some(serialized_panel) = serialized_panel {
325 // panel.update(cx, |panel, cx| {
326 // panel.width = serialized_panel.width;
327 // cx.notify();
328 // });
329 // }
330 panel
331 })
332 })
333 }
334
335 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
336 let width = self.width;
337 self.pending_serialization = cx.background_executor().spawn(
338 async move {
339 KEY_VALUE_STORE
340 .write_kvp(
341 PROJECT_PANEL_KEY.into(),
342 serde_json::to_string(&SerializedProjectPanel { width })?,
343 )
344 .await?;
345 anyhow::Ok(())
346 }
347 .log_err(),
348 );
349 }
350
351 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
352 if !self.has_focus {
353 self.has_focus = true;
354 cx.emit(Event::Focus);
355 }
356 }
357
358 fn focus_out(&mut self, _: &mut ViewContext<Self>) {
359 self.has_focus = false;
360 }
361
362 fn deploy_context_menu(
363 &mut self,
364 _position: Point<Pixels>,
365 _entry_id: ProjectEntryId,
366 _cx: &mut ViewContext<Self>,
367 ) {
368 todo!()
369 // let project = self.project.read(cx);
370
371 // let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
372 // id
373 // } else {
374 // return;
375 // };
376
377 // self.selection = Some(Selection {
378 // worktree_id,
379 // entry_id,
380 // });
381
382 // let mut menu_entries = Vec::new();
383 // if let Some((worktree, entry)) = self.selected_entry(cx) {
384 // let is_root = Some(entry) == worktree.root_entry();
385 // if !project.is_remote() {
386 // menu_entries.push(ContextMenuItem::action(
387 // "Add Folder to Project",
388 // workspace::AddFolderToProject,
389 // ));
390 // if is_root {
391 // let project = self.project.clone();
392 // menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
393 // project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
394 // }));
395 // }
396 // }
397 // menu_entries.push(ContextMenuItem::action("New File", NewFile));
398 // menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
399 // menu_entries.push(ContextMenuItem::Separator);
400 // menu_entries.push(ContextMenuItem::action("Cut", Cut));
401 // menu_entries.push(ContextMenuItem::action("Copy", Copy));
402 // if let Some(clipboard_entry) = self.clipboard_entry {
403 // if clipboard_entry.worktree_id() == worktree.id() {
404 // menu_entries.push(ContextMenuItem::action("Paste", Paste));
405 // }
406 // }
407 // menu_entries.push(ContextMenuItem::Separator);
408 // menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
409 // menu_entries.push(ContextMenuItem::action(
410 // "Copy Relative Path",
411 // CopyRelativePath,
412 // ));
413
414 // if entry.is_dir() {
415 // menu_entries.push(ContextMenuItem::Separator);
416 // }
417 // menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
418 // if entry.is_dir() {
419 // menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
420 // menu_entries.push(ContextMenuItem::action(
421 // "Search Inside",
422 // NewSearchInDirectory,
423 // ));
424 // }
425
426 // menu_entries.push(ContextMenuItem::Separator);
427 // menu_entries.push(ContextMenuItem::action("Rename", Rename));
428 // if !is_root {
429 // menu_entries.push(ContextMenuItem::action("Delete", Delete));
430 // }
431 // }
432
433 // // self.context_menu.update(cx, |menu, cx| {
434 // // menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
435 // // });
436
437 // cx.notify();
438 }
439
440 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
441 if let Some((worktree, entry)) = self.selected_entry(cx) {
442 if entry.is_dir() {
443 let worktree_id = worktree.id();
444 let entry_id = entry.id;
445 let expanded_dir_ids =
446 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
447 expanded_dir_ids
448 } else {
449 return;
450 };
451
452 match expanded_dir_ids.binary_search(&entry_id) {
453 Ok(_) => self.select_next(&SelectNext, cx),
454 Err(ix) => {
455 self.project.update(cx, |project, cx| {
456 project.expand_entry(worktree_id, entry_id, cx);
457 });
458
459 expanded_dir_ids.insert(ix, entry_id);
460 self.update_visible_entries(None, cx);
461 cx.notify();
462 }
463 }
464 }
465 }
466 }
467
468 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
469 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
470 let worktree_id = worktree.id();
471 let expanded_dir_ids =
472 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
473 expanded_dir_ids
474 } else {
475 return;
476 };
477
478 loop {
479 let entry_id = entry.id;
480 match expanded_dir_ids.binary_search(&entry_id) {
481 Ok(ix) => {
482 expanded_dir_ids.remove(ix);
483 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
484 cx.notify();
485 break;
486 }
487 Err(_) => {
488 if let Some(parent_entry) =
489 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
490 {
491 entry = parent_entry;
492 } else {
493 break;
494 }
495 }
496 }
497 }
498 }
499 }
500
501 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
502 self.expanded_dir_ids.clear();
503 self.update_visible_entries(None, cx);
504 cx.notify();
505 }
506
507 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
508 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
509 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
510 self.project.update(cx, |project, cx| {
511 match expanded_dir_ids.binary_search(&entry_id) {
512 Ok(ix) => {
513 expanded_dir_ids.remove(ix);
514 }
515 Err(ix) => {
516 project.expand_entry(worktree_id, entry_id, cx);
517 expanded_dir_ids.insert(ix, entry_id);
518 }
519 }
520 });
521 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
522 cx.focus(&self.focus_handle);
523 cx.notify();
524 }
525 }
526 }
527
528 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
529 if let Some(selection) = self.selection {
530 let (mut worktree_ix, mut entry_ix, _) =
531 self.index_for_selection(selection).unwrap_or_default();
532 if entry_ix > 0 {
533 entry_ix -= 1;
534 } else if worktree_ix > 0 {
535 worktree_ix -= 1;
536 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
537 } else {
538 return;
539 }
540
541 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
542 self.selection = Some(Selection {
543 worktree_id: *worktree_id,
544 entry_id: worktree_entries[entry_ix].id,
545 });
546 self.autoscroll(cx);
547 cx.notify();
548 } else {
549 self.select_first(cx);
550 }
551 }
552
553 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
554 if let Some(task) = self.confirm_edit(cx) {
555 task.detach_and_log_err(cx);
556 }
557 }
558
559 fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
560 if let Some((_, entry)) = self.selected_entry(cx) {
561 if entry.is_file() {
562 self.open_entry(entry.id, true, cx);
563 }
564 }
565 }
566
567 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
568 let edit_state = self.edit_state.as_mut()?;
569 cx.focus(&self.focus_handle);
570
571 let worktree_id = edit_state.worktree_id;
572 let is_new_entry = edit_state.is_new_entry;
573 let is_dir = edit_state.is_dir;
574 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
575 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
576 let filename = self.filename_editor.read(cx).text(cx);
577
578 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
579 let edit_task;
580 let edited_entry_id;
581 if is_new_entry {
582 self.selection = Some(Selection {
583 worktree_id,
584 entry_id: NEW_ENTRY_ID,
585 });
586 let new_path = entry.path.join(&filename.trim_start_matches("/"));
587 if path_already_exists(new_path.as_path()) {
588 return None;
589 }
590
591 edited_entry_id = NEW_ENTRY_ID;
592 edit_task = self.project.update(cx, |project, cx| {
593 project.create_entry((worktree_id, &new_path), is_dir, cx)
594 })?;
595 } else {
596 let new_path = if let Some(parent) = entry.path.clone().parent() {
597 parent.join(&filename)
598 } else {
599 filename.clone().into()
600 };
601 if path_already_exists(new_path.as_path()) {
602 return None;
603 }
604
605 edited_entry_id = entry.id;
606 edit_task = self.project.update(cx, |project, cx| {
607 project.rename_entry(entry.id, new_path.as_path(), cx)
608 })?;
609 };
610
611 edit_state.processing_filename = Some(filename);
612 cx.notify();
613
614 Some(cx.spawn(|this, mut cx| async move {
615 let new_entry = edit_task.await;
616 this.update(&mut cx, |this, cx| {
617 this.edit_state.take();
618 cx.notify();
619 })?;
620
621 let new_entry = new_entry?;
622 this.update(&mut cx, |this, cx| {
623 if let Some(selection) = &mut this.selection {
624 if selection.entry_id == edited_entry_id {
625 selection.worktree_id = worktree_id;
626 selection.entry_id = new_entry.id;
627 this.expand_to_selection(cx);
628 }
629 }
630 this.update_visible_entries(None, cx);
631 if is_new_entry && !is_dir {
632 this.open_entry(new_entry.id, true, cx);
633 }
634 cx.notify();
635 })?;
636 Ok(())
637 }))
638 }
639
640 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
641 self.edit_state = None;
642 self.update_visible_entries(None, cx);
643 cx.focus(&self.focus_handle);
644 cx.notify();
645 }
646
647 fn open_entry(
648 &mut self,
649 entry_id: ProjectEntryId,
650 focus_opened_item: bool,
651 cx: &mut ViewContext<Self>,
652 ) {
653 cx.emit(Event::OpenedEntry {
654 entry_id,
655 focus_opened_item,
656 });
657 }
658
659 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
660 cx.emit(Event::SplitEntry { entry_id });
661 }
662
663 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
664 self.add_entry(false, cx)
665 }
666
667 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
668 self.add_entry(true, cx)
669 }
670
671 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
672 if let Some(Selection {
673 worktree_id,
674 entry_id,
675 }) = self.selection
676 {
677 let directory_id;
678 if let Some((worktree, expanded_dir_ids)) = self
679 .project
680 .read(cx)
681 .worktree_for_id(worktree_id, cx)
682 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
683 {
684 let worktree = worktree.read(cx);
685 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
686 loop {
687 if entry.is_dir() {
688 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
689 expanded_dir_ids.insert(ix, entry.id);
690 }
691 directory_id = entry.id;
692 break;
693 } else {
694 if let Some(parent_path) = entry.path.parent() {
695 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
696 entry = parent_entry;
697 continue;
698 }
699 }
700 return;
701 }
702 }
703 } else {
704 return;
705 };
706 } else {
707 return;
708 };
709
710 self.edit_state = Some(EditState {
711 worktree_id,
712 entry_id: directory_id,
713 is_new_entry: true,
714 is_dir,
715 processing_filename: None,
716 });
717 self.filename_editor.update(cx, |editor, cx| {
718 editor.clear(cx);
719 editor.focus(cx);
720 });
721 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
722 self.autoscroll(cx);
723 cx.notify();
724 }
725 }
726
727 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
728 if let Some(Selection {
729 worktree_id,
730 entry_id,
731 }) = self.selection
732 {
733 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
734 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
735 self.edit_state = Some(EditState {
736 worktree_id,
737 entry_id,
738 is_new_entry: false,
739 is_dir: entry.is_dir(),
740 processing_filename: None,
741 });
742 let file_name = entry
743 .path
744 .file_name()
745 .map(|s| s.to_string_lossy())
746 .unwrap_or_default()
747 .to_string();
748 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
749 let selection_end =
750 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
751 self.filename_editor.update(cx, |editor, cx| {
752 editor.set_text(file_name, cx);
753 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
754 s.select_ranges([0..selection_end])
755 });
756 editor.focus(cx);
757 });
758 self.update_visible_entries(None, cx);
759 self.autoscroll(cx);
760 cx.notify();
761 }
762 }
763
764 // cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
765 // drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
766 // })
767 }
768 }
769
770 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
771 maybe!({
772 let Selection { entry_id, .. } = self.selection?;
773 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
774 let file_name = path.file_name()?;
775
776 let answer = cx.prompt(
777 PromptLevel::Info,
778 &format!("Delete {file_name:?}?"),
779 &["Delete", "Cancel"],
780 );
781
782 cx.spawn(|this, mut cx| async move {
783 if answer.await != Ok(0) {
784 return Ok(());
785 }
786 this.update(&mut cx, |this, cx| {
787 this.project
788 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
789 .ok_or_else(|| anyhow!("no such entry"))
790 })??
791 .await
792 })
793 .detach_and_log_err(cx);
794 Some(())
795 });
796 }
797
798 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
799 if let Some(selection) = self.selection {
800 let (mut worktree_ix, mut entry_ix, _) =
801 self.index_for_selection(selection).unwrap_or_default();
802 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
803 if entry_ix + 1 < worktree_entries.len() {
804 entry_ix += 1;
805 } else {
806 worktree_ix += 1;
807 entry_ix = 0;
808 }
809 }
810
811 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
812 if let Some(entry) = worktree_entries.get(entry_ix) {
813 self.selection = Some(Selection {
814 worktree_id: *worktree_id,
815 entry_id: entry.id,
816 });
817 self.autoscroll(cx);
818 cx.notify();
819 }
820 }
821 } else {
822 self.select_first(cx);
823 }
824 }
825
826 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
827 let worktree = self
828 .visible_entries
829 .first()
830 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
831 if let Some(worktree) = worktree {
832 let worktree = worktree.read(cx);
833 let worktree_id = worktree.id();
834 if let Some(root_entry) = worktree.root_entry() {
835 self.selection = Some(Selection {
836 worktree_id,
837 entry_id: root_entry.id,
838 });
839 self.autoscroll(cx);
840 cx.notify();
841 }
842 }
843 }
844
845 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
846 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
847 self.list.scroll_to_item(index);
848 cx.notify();
849 }
850 }
851
852 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
853 if let Some((worktree, entry)) = self.selected_entry(cx) {
854 self.clipboard_entry = Some(ClipboardEntry::Cut {
855 worktree_id: worktree.id(),
856 entry_id: entry.id,
857 });
858 cx.notify();
859 }
860 }
861
862 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
863 if let Some((worktree, entry)) = self.selected_entry(cx) {
864 self.clipboard_entry = Some(ClipboardEntry::Copied {
865 worktree_id: worktree.id(),
866 entry_id: entry.id,
867 });
868 cx.notify();
869 }
870 }
871
872 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
873 maybe!({
874 let (worktree, entry) = self.selected_entry(cx)?;
875 let clipboard_entry = self.clipboard_entry?;
876 if clipboard_entry.worktree_id() != worktree.id() {
877 return None;
878 }
879
880 let clipboard_entry_file_name = self
881 .project
882 .read(cx)
883 .path_for_entry(clipboard_entry.entry_id(), cx)?
884 .path
885 .file_name()?
886 .to_os_string();
887
888 let mut new_path = entry.path.to_path_buf();
889 if entry.is_file() {
890 new_path.pop();
891 }
892
893 new_path.push(&clipboard_entry_file_name);
894 let extension = new_path.extension().map(|e| e.to_os_string());
895 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
896 let mut ix = 0;
897 while worktree.entry_for_path(&new_path).is_some() {
898 new_path.pop();
899
900 let mut new_file_name = file_name_without_extension.to_os_string();
901 new_file_name.push(" copy");
902 if ix > 0 {
903 new_file_name.push(format!(" {}", ix));
904 }
905 if let Some(extension) = extension.as_ref() {
906 new_file_name.push(".");
907 new_file_name.push(extension);
908 }
909
910 new_path.push(new_file_name);
911 ix += 1;
912 }
913
914 if clipboard_entry.is_cut() {
915 if let Some(task) = self.project.update(cx, |project, cx| {
916 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
917 }) {
918 task.detach_and_log_err(cx);
919 }
920 } else if let Some(task) = self.project.update(cx, |project, cx| {
921 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
922 }) {
923 task.detach_and_log_err(cx);
924 }
925
926 Some(())
927 });
928 }
929
930 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
931 if let Some((worktree, entry)) = self.selected_entry(cx) {
932 cx.write_to_clipboard(ClipboardItem::new(
933 worktree
934 .abs_path()
935 .join(&entry.path)
936 .to_string_lossy()
937 .to_string(),
938 ));
939 }
940 }
941
942 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
943 if let Some((_, entry)) = self.selected_entry(cx) {
944 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
945 }
946 }
947
948 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
949 if let Some((worktree, entry)) = self.selected_entry(cx) {
950 cx.reveal_path(&worktree.abs_path().join(&entry.path));
951 }
952 }
953
954 fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
955 todo!()
956 // if let Some((worktree, entry)) = self.selected_entry(cx) {
957 // let window = cx.window();
958 // let view_id = cx.view_id();
959 // let path = worktree.abs_path().join(&entry.path);
960
961 // cx.app_context()
962 // .spawn(|mut cx| async move {
963 // window.dispatch_action(
964 // view_id,
965 // &workspace::OpenTerminal {
966 // working_directory: path,
967 // },
968 // &mut cx,
969 // );
970 // })
971 // .detach();
972 // }
973 }
974
975 pub fn new_search_in_directory(
976 &mut self,
977 _: &NewSearchInDirectory,
978 cx: &mut ViewContext<Self>,
979 ) {
980 if let Some((_, entry)) = self.selected_entry(cx) {
981 if entry.is_dir() {
982 cx.emit(Event::NewSearchInDirectory {
983 dir_entry: entry.clone(),
984 });
985 }
986 }
987 }
988
989 // todo!()
990 // fn move_entry(
991 // &mut self,
992 // entry_to_move: ProjectEntryId,
993 // destination: ProjectEntryId,
994 // destination_is_file: bool,
995 // cx: &mut ViewContext<Self>,
996 // ) {
997 // let destination_worktree = self.project.update(cx, |project, cx| {
998 // let entry_path = project.path_for_entry(entry_to_move, cx)?;
999 // let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1000
1001 // let mut destination_path = destination_entry_path.as_ref();
1002 // if destination_is_file {
1003 // destination_path = destination_path.parent()?;
1004 // }
1005
1006 // let mut new_path = destination_path.to_path_buf();
1007 // new_path.push(entry_path.path.file_name()?);
1008 // if new_path != entry_path.path.as_ref() {
1009 // let task = project.rename_entry(entry_to_move, new_path, cx)?;
1010 // cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1011 // }
1012
1013 // Some(project.worktree_id_for_entry(destination, cx)?)
1014 // });
1015
1016 // if let Some(destination_worktree) = destination_worktree {
1017 // self.expand_entry(destination_worktree, destination, cx);
1018 // }
1019 // }
1020
1021 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1022 let mut entry_index = 0;
1023 let mut visible_entries_index = 0;
1024 for (worktree_index, (worktree_id, worktree_entries)) in
1025 self.visible_entries.iter().enumerate()
1026 {
1027 if *worktree_id == selection.worktree_id {
1028 for entry in worktree_entries {
1029 if entry.id == selection.entry_id {
1030 return Some((worktree_index, entry_index, visible_entries_index));
1031 } else {
1032 visible_entries_index += 1;
1033 entry_index += 1;
1034 }
1035 }
1036 break;
1037 } else {
1038 visible_entries_index += worktree_entries.len();
1039 }
1040 }
1041 None
1042 }
1043
1044 pub fn selected_entry<'a>(
1045 &self,
1046 cx: &'a AppContext,
1047 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1048 let (worktree, entry) = self.selected_entry_handle(cx)?;
1049 Some((worktree.read(cx), entry))
1050 }
1051
1052 fn selected_entry_handle<'a>(
1053 &self,
1054 cx: &'a AppContext,
1055 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1056 let selection = self.selection?;
1057 let project = self.project.read(cx);
1058 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1059 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1060 Some((worktree, entry))
1061 }
1062
1063 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1064 let (worktree, entry) = self.selected_entry(cx)?;
1065 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1066
1067 for path in entry.path.ancestors() {
1068 let Some(entry) = worktree.entry_for_path(path) else {
1069 continue;
1070 };
1071 if entry.is_dir() {
1072 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1073 expanded_dir_ids.insert(idx, entry.id);
1074 }
1075 }
1076 }
1077
1078 Some(())
1079 }
1080
1081 fn update_visible_entries(
1082 &mut self,
1083 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1084 cx: &mut ViewContext<Self>,
1085 ) {
1086 let project = self.project.read(cx);
1087 self.last_worktree_root_id = project
1088 .visible_worktrees(cx)
1089 .rev()
1090 .next()
1091 .and_then(|worktree| worktree.read(cx).root_entry())
1092 .map(|entry| entry.id);
1093
1094 self.visible_entries.clear();
1095 for worktree in project.visible_worktrees(cx) {
1096 let snapshot = worktree.read(cx).snapshot();
1097 let worktree_id = snapshot.id();
1098
1099 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1100 hash_map::Entry::Occupied(e) => e.into_mut(),
1101 hash_map::Entry::Vacant(e) => {
1102 // The first time a worktree's root entry becomes available,
1103 // mark that root entry as expanded.
1104 if let Some(entry) = snapshot.root_entry() {
1105 e.insert(vec![entry.id]).as_slice()
1106 } else {
1107 &[]
1108 }
1109 }
1110 };
1111
1112 let mut new_entry_parent_id = None;
1113 let mut new_entry_kind = EntryKind::Dir;
1114 if let Some(edit_state) = &self.edit_state {
1115 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1116 new_entry_parent_id = Some(edit_state.entry_id);
1117 new_entry_kind = if edit_state.is_dir {
1118 EntryKind::Dir
1119 } else {
1120 EntryKind::File(Default::default())
1121 };
1122 }
1123 }
1124
1125 let mut visible_worktree_entries = Vec::new();
1126 let mut entry_iter = snapshot.entries(true);
1127
1128 while let Some(entry) = entry_iter.entry() {
1129 visible_worktree_entries.push(entry.clone());
1130 if Some(entry.id) == new_entry_parent_id {
1131 visible_worktree_entries.push(Entry {
1132 id: NEW_ENTRY_ID,
1133 kind: new_entry_kind,
1134 path: entry.path.join("\0").into(),
1135 inode: 0,
1136 mtime: entry.mtime,
1137 is_symlink: false,
1138 is_ignored: false,
1139 is_external: false,
1140 git_status: entry.git_status,
1141 });
1142 }
1143 if expanded_dir_ids.binary_search(&entry.id).is_err()
1144 && entry_iter.advance_to_sibling()
1145 {
1146 continue;
1147 }
1148 entry_iter.advance();
1149 }
1150
1151 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1152
1153 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1154 let mut components_a = entry_a.path.components().peekable();
1155 let mut components_b = entry_b.path.components().peekable();
1156 loop {
1157 match (components_a.next(), components_b.next()) {
1158 (Some(component_a), Some(component_b)) => {
1159 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1160 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1161 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1162 let name_a =
1163 UniCase::new(component_a.as_os_str().to_string_lossy());
1164 let name_b =
1165 UniCase::new(component_b.as_os_str().to_string_lossy());
1166 name_a.cmp(&name_b)
1167 });
1168 if !ordering.is_eq() {
1169 return ordering;
1170 }
1171 }
1172 (Some(_), None) => break Ordering::Greater,
1173 (None, Some(_)) => break Ordering::Less,
1174 (None, None) => break Ordering::Equal,
1175 }
1176 }
1177 });
1178 self.visible_entries
1179 .push((worktree_id, visible_worktree_entries));
1180 }
1181
1182 if let Some((worktree_id, entry_id)) = new_selected_entry {
1183 self.selection = Some(Selection {
1184 worktree_id,
1185 entry_id,
1186 });
1187 }
1188 }
1189
1190 fn expand_entry(
1191 &mut self,
1192 worktree_id: WorktreeId,
1193 entry_id: ProjectEntryId,
1194 cx: &mut ViewContext<Self>,
1195 ) {
1196 self.project.update(cx, |project, cx| {
1197 if let Some((worktree, expanded_dir_ids)) = project
1198 .worktree_for_id(worktree_id, cx)
1199 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1200 {
1201 project.expand_entry(worktree_id, entry_id, cx);
1202 let worktree = worktree.read(cx);
1203
1204 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1205 loop {
1206 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1207 expanded_dir_ids.insert(ix, entry.id);
1208 }
1209
1210 if let Some(parent_entry) =
1211 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1212 {
1213 entry = parent_entry;
1214 } else {
1215 break;
1216 }
1217 }
1218 }
1219 }
1220 });
1221 }
1222
1223 fn for_each_visible_entry(
1224 &self,
1225 range: Range<usize>,
1226 cx: &mut ViewContext<ProjectPanel>,
1227 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1228 ) {
1229 let mut ix = 0;
1230 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1231 if ix >= range.end {
1232 return;
1233 }
1234
1235 if ix + visible_worktree_entries.len() <= range.start {
1236 ix += visible_worktree_entries.len();
1237 continue;
1238 }
1239
1240 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1241 let (git_status_setting, show_file_icons, show_folder_icons) = {
1242 let settings = ProjectPanelSettings::get_global(cx);
1243 (
1244 settings.git_status,
1245 settings.file_icons,
1246 settings.folder_icons,
1247 )
1248 };
1249 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1250 let snapshot = worktree.read(cx).snapshot();
1251 let root_name = OsStr::new(snapshot.root_name());
1252 let expanded_entry_ids = self
1253 .expanded_dir_ids
1254 .get(&snapshot.id())
1255 .map(Vec::as_slice)
1256 .unwrap_or(&[]);
1257
1258 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1259 for entry in visible_worktree_entries[entry_range].iter() {
1260 let status = git_status_setting.then(|| entry.git_status).flatten();
1261 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1262 let icon = match entry.kind {
1263 EntryKind::File(_) => {
1264 if show_file_icons {
1265 Some(FileAssociations::get_icon(&entry.path, cx))
1266 } else {
1267 None
1268 }
1269 }
1270 _ => {
1271 if show_folder_icons {
1272 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1273 } else {
1274 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1275 }
1276 }
1277 };
1278
1279 let mut details = EntryDetails {
1280 filename: entry
1281 .path
1282 .file_name()
1283 .unwrap_or(root_name)
1284 .to_string_lossy()
1285 .to_string(),
1286 icon,
1287 path: entry.path.clone(),
1288 depth: entry.path.components().count(),
1289 kind: entry.kind,
1290 is_ignored: entry.is_ignored,
1291 is_expanded,
1292 is_selected: self.selection.map_or(false, |e| {
1293 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1294 }),
1295 is_editing: false,
1296 is_processing: false,
1297 is_cut: self
1298 .clipboard_entry
1299 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1300 git_status: status,
1301 };
1302
1303 if let Some(edit_state) = &self.edit_state {
1304 let is_edited_entry = if edit_state.is_new_entry {
1305 entry.id == NEW_ENTRY_ID
1306 } else {
1307 entry.id == edit_state.entry_id
1308 };
1309
1310 if is_edited_entry {
1311 if let Some(processing_filename) = &edit_state.processing_filename {
1312 details.is_processing = true;
1313 details.filename.clear();
1314 details.filename.push_str(processing_filename);
1315 } else {
1316 if edit_state.is_new_entry {
1317 details.filename.clear();
1318 }
1319 details.is_editing = true;
1320 }
1321 }
1322 }
1323
1324 callback(entry.id, details, cx);
1325 }
1326 }
1327 ix = end_ix;
1328 }
1329 }
1330
1331 fn render_entry_visual_element(
1332 details: &EntryDetails,
1333 editor: Option<&View<Editor>>,
1334 padding: Pixels,
1335 cx: &mut ViewContext<Self>,
1336 ) -> Div<Self> {
1337 let show_editor = details.is_editing && !details.is_processing;
1338
1339 let theme = cx.theme();
1340 let filename_text_color = details
1341 .git_status
1342 .as_ref()
1343 .map(|status| match status {
1344 GitFileStatus::Added => theme.status().created,
1345 GitFileStatus::Modified => theme.status().modified,
1346 GitFileStatus::Conflict => theme.status().conflict,
1347 })
1348 .unwrap_or(theme.status().info);
1349
1350 h_stack()
1351 .child(if let Some(icon) = &details.icon {
1352 div().child(IconElement::from_path(icon.to_string()))
1353 } else {
1354 div()
1355 })
1356 .child(
1357 if let (Some(editor), true) = (editor, show_editor) {
1358 div().w_full().child(editor.clone())
1359 } else {
1360 div()
1361 .text_color(filename_text_color)
1362 .child(Label::new(details.filename.clone()))
1363 }
1364 .ml_1(),
1365 )
1366 .pl(padding)
1367 }
1368
1369 fn render_entry(
1370 &self,
1371 entry_id: ProjectEntryId,
1372 details: EntryDetails,
1373 // dragged_entry_destination: &mut Option<Arc<Path>>,
1374 cx: &mut ViewContext<Self>,
1375 ) -> Stateful<Self, Div<Self>> {
1376 let kind = details.kind;
1377 let settings = ProjectPanelSettings::get_global(cx);
1378 const INDENT_SIZE: Pixels = px(16.0);
1379 let padding = INDENT_SIZE + details.depth as f32 * px(settings.indent_size);
1380 let show_editor = details.is_editing && !details.is_processing;
1381 let is_selected = self
1382 .selection
1383 .map_or(false, |selection| selection.entry_id == entry_id);
1384
1385 Self::render_entry_visual_element(&details, Some(&self.filename_editor), padding, cx)
1386 .id(entry_id.to_proto() as usize)
1387 .w_full()
1388 .cursor_pointer()
1389 .when(is_selected, |this| {
1390 this.bg(cx.theme().colors().element_selected)
1391 })
1392 .hover(|style| style.bg(cx.theme().colors().element_hover))
1393 .on_click(move |this, event, cx| {
1394 if !show_editor {
1395 if kind.is_dir() {
1396 this.toggle_expanded(entry_id, cx);
1397 } else {
1398 if event.down.modifiers.command {
1399 this.split_entry(entry_id, cx);
1400 } else {
1401 this.open_entry(entry_id, event.up.click_count > 1, cx);
1402 }
1403 }
1404 }
1405 })
1406 .on_mouse_down(MouseButton::Right, move |this, event, cx| {
1407 this.deploy_context_menu(event.position, entry_id, cx);
1408 })
1409 // .on_drop::<ProjectEntryId>(|this, event, cx| {
1410 // this.move_entry(
1411 // *dragged_entry,
1412 // entry_id,
1413 // matches!(details.kind, EntryKind::File(_)),
1414 // cx,
1415 // );
1416 // })
1417 }
1418}
1419
1420impl Render for ProjectPanel {
1421 type Element = Focusable<Self, Stateful<Self, Div<Self>>>;
1422
1423 fn render(&mut self, _cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1424 let has_worktree = self.visible_entries.len() != 0;
1425
1426 if has_worktree {
1427 div()
1428 .id("project-panel")
1429 .size_full()
1430 .key_context("ProjectPanel")
1431 .on_action(Self::select_next)
1432 .on_action(Self::select_prev)
1433 .on_action(Self::expand_selected_entry)
1434 .on_action(Self::collapse_selected_entry)
1435 .on_action(Self::collapse_all_entries)
1436 .on_action(Self::new_file)
1437 .on_action(Self::new_directory)
1438 .on_action(Self::rename)
1439 .on_action(Self::delete)
1440 .on_action(Self::confirm)
1441 .on_action(Self::open_file)
1442 .on_action(Self::cancel)
1443 .on_action(Self::cut)
1444 .on_action(Self::copy)
1445 .on_action(Self::copy_path)
1446 .on_action(Self::copy_relative_path)
1447 .on_action(Self::paste)
1448 .on_action(Self::reveal_in_finder)
1449 .on_action(Self::open_in_terminal)
1450 .on_action(Self::new_search_in_directory)
1451 .track_focus(&self.focus_handle)
1452 .child(
1453 uniform_list(
1454 "entries",
1455 self.visible_entries
1456 .iter()
1457 .map(|(_, worktree_entries)| worktree_entries.len())
1458 .sum(),
1459 |this: &mut Self, range, cx| {
1460 let mut items = Vec::new();
1461 this.for_each_visible_entry(range, cx, |id, details, cx| {
1462 items.push(this.render_entry(id, details, cx));
1463 });
1464 items
1465 },
1466 )
1467 .size_full()
1468 .track_scroll(self.list.clone()),
1469 )
1470 } else {
1471 v_stack()
1472 .id("empty-project_panel")
1473 .track_focus(&self.focus_handle)
1474 }
1475 }
1476}
1477
1478impl EventEmitter<Event> for ProjectPanel {}
1479
1480impl EventEmitter<PanelEvent> for ProjectPanel {}
1481
1482impl workspace::dock::Panel for ProjectPanel {
1483 fn position(&self, cx: &WindowContext) -> DockPosition {
1484 match ProjectPanelSettings::get_global(cx).dock {
1485 ProjectPanelDockPosition::Left => DockPosition::Left,
1486 ProjectPanelDockPosition::Right => DockPosition::Right,
1487 }
1488 }
1489
1490 fn position_is_valid(&self, position: DockPosition) -> bool {
1491 matches!(position, DockPosition::Left | DockPosition::Right)
1492 }
1493
1494 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1495 settings::update_settings_file::<ProjectPanelSettings>(
1496 self.fs.clone(),
1497 cx,
1498 move |settings| {
1499 let dock = match position {
1500 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1501 DockPosition::Right => ProjectPanelDockPosition::Right,
1502 };
1503 settings.dock = Some(dock);
1504 },
1505 );
1506 }
1507
1508 fn size(&self, cx: &WindowContext) -> f32 {
1509 self.width
1510 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1511 }
1512
1513 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1514 self.width = size;
1515 self.serialize(cx);
1516 cx.notify();
1517 }
1518
1519 fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1520 Some("icons/project.svg")
1521 }
1522
1523 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1524 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1525 }
1526
1527 // fn should_change_position_on_event(event: &Self::Event) -> bool {
1528 // matches!(event, Event::DockPositionChanged)
1529 // }
1530
1531 fn has_focus(&self, _: &WindowContext) -> bool {
1532 self.has_focus
1533 }
1534
1535 fn persistent_name(&self) -> &'static str {
1536 "Project Panel"
1537 }
1538
1539 fn focus_handle(&self, _cx: &WindowContext) -> FocusHandle {
1540 self.focus_handle.clone()
1541 }
1542
1543 // fn is_focus_event(event: &Self::Event) -> bool {
1544 // matches!(event, Event::Focus)
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::{pane, 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(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(cx);
2802 init_settings(cx);
2803 language::init(cx);
2804 editor::init(cx);
2805 pane::init(cx);
2806 crate::init((), cx);
2807 workspace::init(app_state.clone(), cx);
2808 Project::init_settings(cx);
2809 });
2810 }
2811
2812 fn ensure_single_file_is_opened(
2813 window: &WindowHandle<Workspace>,
2814 expected_path: &str,
2815 cx: &mut TestAppContext,
2816 ) {
2817 window
2818 .update(cx, |workspace, cx| {
2819 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2820 assert_eq!(worktrees.len(), 1);
2821 let worktree_id = worktrees[0].read(cx).id();
2822
2823 let open_project_paths = workspace
2824 .panes()
2825 .iter()
2826 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2827 .collect::<Vec<_>>();
2828 assert_eq!(
2829 open_project_paths,
2830 vec![ProjectPath {
2831 worktree_id,
2832 path: Arc::from(Path::new(expected_path))
2833 }],
2834 "Should have opened file, selected in project panel"
2835 );
2836 })
2837 .unwrap();
2838 }
2839
2840 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2841 assert!(
2842 !cx.has_pending_prompt(),
2843 "Should have no prompts before the deletion"
2844 );
2845 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2846 assert!(
2847 cx.has_pending_prompt(),
2848 "Should have a prompt after the deletion"
2849 );
2850 cx.simulate_prompt_answer(0);
2851 assert!(
2852 !cx.has_pending_prompt(),
2853 "Should have no prompts after prompt was replied to"
2854 );
2855 cx.executor().run_until_parked();
2856 }
2857
2858 fn ensure_no_open_items_and_panes(
2859 workspace: &WindowHandle<Workspace>,
2860 cx: &mut VisualTestContext,
2861 ) {
2862 assert!(
2863 !cx.has_pending_prompt(),
2864 "Should have no prompts after deletion operation closes the file"
2865 );
2866 workspace
2867 .read_with(cx, |workspace, cx| {
2868 let open_project_paths = workspace
2869 .panes()
2870 .iter()
2871 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2872 .collect::<Vec<_>>();
2873 assert!(
2874 open_project_paths.is_empty(),
2875 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2876 );
2877 })
2878 .unwrap();
2879 }
2880}