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