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