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, Entity, EventEmitter, FocusHandle, FocusableKeyDispatch, Model,
13 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 view_id = cx.view().entity_id();
223 let mut this = Self {
224 project: project.clone(),
225 fs: workspace.app_state().fs.clone(),
226 list: UniformListScrollHandle::new(),
227 focus_handle,
228 visible_entries: Default::default(),
229 last_worktree_root_id: Default::default(),
230 expanded_dir_ids: Default::default(),
231 selection: None,
232 edit_state: None,
233 filename_editor,
234 clipboard_entry: None,
235 // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
236 dragged_entry_destination: None,
237 workspace: workspace.weak_handle(),
238 has_focus: false,
239 width: None,
240 pending_serialization: Task::ready(None),
241 };
242 this.update_visible_entries(None, cx);
243
244 // Update the dock position when the setting changes.
245 // todo!()
246 // let mut old_dock_position = this.position(cx);
247 // cx.observe_global::<SettingsStore, _>(move |this, cx| {
248 // let new_dock_position = this.position(cx);
249 // if new_dock_position != old_dock_position {
250 // old_dock_position = new_dock_position;
251 // cx.emit(Event::DockPositionChanged);
252 // }
253 // })
254 // .detach();
255
256 this
257 });
258
259 cx.subscribe(&project_panel, {
260 let project_panel = project_panel.downgrade();
261 move |workspace, _, event, cx| match event {
262 &Event::OpenedEntry {
263 entry_id,
264 focus_opened_item,
265 } => {
266 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
267 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
268 workspace
269 .open_path(
270 ProjectPath {
271 worktree_id: worktree.read(cx).id(),
272 path: entry.path.clone(),
273 },
274 None,
275 focus_opened_item,
276 cx,
277 )
278 .detach_and_log_err(cx);
279 if !focus_opened_item {
280 if let Some(project_panel) = project_panel.upgrade() {
281 let focus_handle = project_panel.read(cx).focus_handle.clone();
282 cx.focus(&focus_handle);
283 }
284 }
285 }
286 }
287 }
288 &Event::SplitEntry { entry_id } => {
289 // if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
290 // if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
291 // workspace
292 // .split_path(
293 // ProjectPath {
294 // worktree_id: worktree.read(cx).id(),
295 // path: entry.path.clone(),
296 // },
297 // cx,
298 // )
299 // .detach_and_log_err(cx);
300 // }
301 // }
302 }
303 _ => {}
304 }
305 })
306 .detach();
307
308 project_panel
309 }
310
311 pub fn load(
312 workspace: WeakView<Workspace>,
313 cx: AsyncWindowContext,
314 ) -> Task<Result<View<Self>>> {
315 cx.spawn(|mut cx| async move {
316 // let serialized_panel = if let Some(panel) = cx
317 // .background_executor()
318 // .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
319 // .await
320 // .log_err()
321 // .flatten()
322 // {
323 // Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
324 // } else {
325 // None
326 // };
327 workspace.update(&mut cx, |workspace, cx| {
328 let panel = ProjectPanel::new(workspace, cx);
329 // if let Some(serialized_panel) = serialized_panel {
330 // panel.update(cx, |panel, cx| {
331 // panel.width = serialized_panel.width;
332 // cx.notify();
333 // });
334 // }
335 panel
336 })
337 })
338 }
339
340 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
341 let width = self.width;
342 self.pending_serialization = cx.background_executor().spawn(
343 async move {
344 KEY_VALUE_STORE
345 .write_kvp(
346 PROJECT_PANEL_KEY.into(),
347 serde_json::to_string(&SerializedProjectPanel { width })?,
348 )
349 .await?;
350 anyhow::Ok(())
351 }
352 .log_err(),
353 );
354 }
355
356 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
357 if !self.has_focus {
358 self.has_focus = true;
359 cx.emit(Event::Focus);
360 }
361 }
362
363 fn focus_out(&mut self, _: &mut ViewContext<Self>) {
364 self.has_focus = false;
365 }
366
367 fn deploy_context_menu(
368 &mut self,
369 position: Point<Pixels>,
370 entry_id: ProjectEntryId,
371 cx: &mut ViewContext<Self>,
372 ) {
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 fn move_entry(
994 &mut self,
995 entry_to_move: ProjectEntryId,
996 destination: ProjectEntryId,
997 destination_is_file: bool,
998 cx: &mut ViewContext<Self>,
999 ) {
1000 let destination_worktree = self.project.update(cx, |project, cx| {
1001 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1002 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1003
1004 let mut destination_path = destination_entry_path.as_ref();
1005 if destination_is_file {
1006 destination_path = destination_path.parent()?;
1007 }
1008
1009 let mut new_path = destination_path.to_path_buf();
1010 new_path.push(entry_path.path.file_name()?);
1011 if new_path != entry_path.path.as_ref() {
1012 let task = project.rename_entry(entry_to_move, new_path, cx)?;
1013 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1014 }
1015
1016 Some(project.worktree_id_for_entry(destination, cx)?)
1017 });
1018
1019 if let Some(destination_worktree) = destination_worktree {
1020 self.expand_entry(destination_worktree, destination, cx);
1021 }
1022 }
1023
1024 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1025 let mut entry_index = 0;
1026 let mut visible_entries_index = 0;
1027 for (worktree_index, (worktree_id, worktree_entries)) in
1028 self.visible_entries.iter().enumerate()
1029 {
1030 if *worktree_id == selection.worktree_id {
1031 for entry in worktree_entries {
1032 if entry.id == selection.entry_id {
1033 return Some((worktree_index, entry_index, visible_entries_index));
1034 } else {
1035 visible_entries_index += 1;
1036 entry_index += 1;
1037 }
1038 }
1039 break;
1040 } else {
1041 visible_entries_index += worktree_entries.len();
1042 }
1043 }
1044 None
1045 }
1046
1047 pub fn selected_entry<'a>(
1048 &self,
1049 cx: &'a AppContext,
1050 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1051 let (worktree, entry) = self.selected_entry_handle(cx)?;
1052 Some((worktree.read(cx), entry))
1053 }
1054
1055 fn selected_entry_handle<'a>(
1056 &self,
1057 cx: &'a AppContext,
1058 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1059 let selection = self.selection?;
1060 let project = self.project.read(cx);
1061 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1062 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1063 Some((worktree, entry))
1064 }
1065
1066 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1067 let (worktree, entry) = self.selected_entry(cx)?;
1068 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1069
1070 for path in entry.path.ancestors() {
1071 let Some(entry) = worktree.entry_for_path(path) else {
1072 continue;
1073 };
1074 if entry.is_dir() {
1075 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1076 expanded_dir_ids.insert(idx, entry.id);
1077 }
1078 }
1079 }
1080
1081 Some(())
1082 }
1083
1084 fn update_visible_entries(
1085 &mut self,
1086 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1087 cx: &mut ViewContext<Self>,
1088 ) {
1089 let project = self.project.read(cx);
1090 self.last_worktree_root_id = project
1091 .visible_worktrees(cx)
1092 .rev()
1093 .next()
1094 .and_then(|worktree| worktree.read(cx).root_entry())
1095 .map(|entry| entry.id);
1096
1097 self.visible_entries.clear();
1098 for worktree in project.visible_worktrees(cx) {
1099 let snapshot = worktree.read(cx).snapshot();
1100 let worktree_id = snapshot.id();
1101
1102 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1103 hash_map::Entry::Occupied(e) => e.into_mut(),
1104 hash_map::Entry::Vacant(e) => {
1105 // The first time a worktree's root entry becomes available,
1106 // mark that root entry as expanded.
1107 if let Some(entry) = snapshot.root_entry() {
1108 e.insert(vec![entry.id]).as_slice()
1109 } else {
1110 &[]
1111 }
1112 }
1113 };
1114
1115 let mut new_entry_parent_id = None;
1116 let mut new_entry_kind = EntryKind::Dir;
1117 if let Some(edit_state) = &self.edit_state {
1118 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1119 new_entry_parent_id = Some(edit_state.entry_id);
1120 new_entry_kind = if edit_state.is_dir {
1121 EntryKind::Dir
1122 } else {
1123 EntryKind::File(Default::default())
1124 };
1125 }
1126 }
1127
1128 let mut visible_worktree_entries = Vec::new();
1129 let mut entry_iter = snapshot.entries(true);
1130
1131 while let Some(entry) = entry_iter.entry() {
1132 visible_worktree_entries.push(entry.clone());
1133 if Some(entry.id) == new_entry_parent_id {
1134 visible_worktree_entries.push(Entry {
1135 id: NEW_ENTRY_ID,
1136 kind: new_entry_kind,
1137 path: entry.path.join("\0").into(),
1138 inode: 0,
1139 mtime: entry.mtime,
1140 is_symlink: false,
1141 is_ignored: false,
1142 is_external: false,
1143 git_status: entry.git_status,
1144 });
1145 }
1146 if expanded_dir_ids.binary_search(&entry.id).is_err()
1147 && entry_iter.advance_to_sibling()
1148 {
1149 continue;
1150 }
1151 entry_iter.advance();
1152 }
1153
1154 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1155
1156 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1157 let mut components_a = entry_a.path.components().peekable();
1158 let mut components_b = entry_b.path.components().peekable();
1159 loop {
1160 match (components_a.next(), components_b.next()) {
1161 (Some(component_a), Some(component_b)) => {
1162 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1163 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1164 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1165 let name_a =
1166 UniCase::new(component_a.as_os_str().to_string_lossy());
1167 let name_b =
1168 UniCase::new(component_b.as_os_str().to_string_lossy());
1169 name_a.cmp(&name_b)
1170 });
1171 if !ordering.is_eq() {
1172 return ordering;
1173 }
1174 }
1175 (Some(_), None) => break Ordering::Greater,
1176 (None, Some(_)) => break Ordering::Less,
1177 (None, None) => break Ordering::Equal,
1178 }
1179 }
1180 });
1181 self.visible_entries
1182 .push((worktree_id, visible_worktree_entries));
1183 }
1184
1185 if let Some((worktree_id, entry_id)) = new_selected_entry {
1186 self.selection = Some(Selection {
1187 worktree_id,
1188 entry_id,
1189 });
1190 }
1191 }
1192
1193 fn expand_entry(
1194 &mut self,
1195 worktree_id: WorktreeId,
1196 entry_id: ProjectEntryId,
1197 cx: &mut ViewContext<Self>,
1198 ) {
1199 self.project.update(cx, |project, cx| {
1200 if let Some((worktree, expanded_dir_ids)) = project
1201 .worktree_for_id(worktree_id, cx)
1202 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1203 {
1204 project.expand_entry(worktree_id, entry_id, cx);
1205 let worktree = worktree.read(cx);
1206
1207 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1208 loop {
1209 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1210 expanded_dir_ids.insert(ix, entry.id);
1211 }
1212
1213 if let Some(parent_entry) =
1214 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1215 {
1216 entry = parent_entry;
1217 } else {
1218 break;
1219 }
1220 }
1221 }
1222 }
1223 });
1224 }
1225
1226 fn for_each_visible_entry(
1227 &self,
1228 range: Range<usize>,
1229 cx: &mut ViewContext<ProjectPanel>,
1230 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1231 ) {
1232 let mut ix = 0;
1233 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1234 if ix >= range.end {
1235 return;
1236 }
1237
1238 if ix + visible_worktree_entries.len() <= range.start {
1239 ix += visible_worktree_entries.len();
1240 continue;
1241 }
1242
1243 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1244 let (git_status_setting, show_file_icons, show_folder_icons) = {
1245 let settings = ProjectPanelSettings::get_global(cx);
1246 (
1247 settings.git_status,
1248 settings.file_icons,
1249 settings.folder_icons,
1250 )
1251 };
1252 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1253 let snapshot = worktree.read(cx).snapshot();
1254 let root_name = OsStr::new(snapshot.root_name());
1255 let expanded_entry_ids = self
1256 .expanded_dir_ids
1257 .get(&snapshot.id())
1258 .map(Vec::as_slice)
1259 .unwrap_or(&[]);
1260
1261 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1262 for entry in visible_worktree_entries[entry_range].iter() {
1263 let status = git_status_setting.then(|| entry.git_status).flatten();
1264 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1265 let icon = match entry.kind {
1266 EntryKind::File(_) => {
1267 if show_file_icons {
1268 Some(FileAssociations::get_icon(&entry.path, cx))
1269 } else {
1270 None
1271 }
1272 }
1273 _ => {
1274 if show_folder_icons {
1275 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1276 } else {
1277 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1278 }
1279 }
1280 };
1281
1282 let mut details = EntryDetails {
1283 filename: entry
1284 .path
1285 .file_name()
1286 .unwrap_or(root_name)
1287 .to_string_lossy()
1288 .to_string(),
1289 icon,
1290 path: entry.path.clone(),
1291 depth: entry.path.components().count(),
1292 kind: entry.kind,
1293 is_ignored: entry.is_ignored,
1294 is_expanded,
1295 is_selected: self.selection.map_or(false, |e| {
1296 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1297 }),
1298 is_editing: false,
1299 is_processing: false,
1300 is_cut: self
1301 .clipboard_entry
1302 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1303 git_status: status,
1304 };
1305
1306 if let Some(edit_state) = &self.edit_state {
1307 let is_edited_entry = if edit_state.is_new_entry {
1308 entry.id == NEW_ENTRY_ID
1309 } else {
1310 entry.id == edit_state.entry_id
1311 };
1312
1313 if is_edited_entry {
1314 if let Some(processing_filename) = &edit_state.processing_filename {
1315 details.is_processing = true;
1316 details.filename.clear();
1317 details.filename.push_str(processing_filename);
1318 } else {
1319 if edit_state.is_new_entry {
1320 details.filename.clear();
1321 }
1322 details.is_editing = true;
1323 }
1324 }
1325 }
1326
1327 callback(entry.id, details, cx);
1328 }
1329 }
1330 ix = end_ix;
1331 }
1332 }
1333
1334 fn render_entry_visual_element(
1335 details: &EntryDetails,
1336 editor: Option<&View<Editor>>,
1337 padding: Pixels,
1338 cx: &mut ViewContext<Self>,
1339 ) -> Div<Self> {
1340 let show_editor = details.is_editing && !details.is_processing;
1341
1342 let theme = cx.theme();
1343 let filename_text_color = details
1344 .git_status
1345 .as_ref()
1346 .map(|status| match status {
1347 GitFileStatus::Added => theme.status().created,
1348 GitFileStatus::Modified => theme.status().modified,
1349 GitFileStatus::Conflict => theme.status().conflict,
1350 })
1351 .unwrap_or(theme.status().info);
1352
1353 h_stack()
1354 .child(if let Some(icon) = &details.icon {
1355 div().child(
1356 // todo!() Marshall: Can we use our `IconElement` component here?
1357 svg()
1358 .size(rems(0.9375))
1359 .flex_none()
1360 .path(icon.to_string())
1361 .text_color(cx.theme().colors().icon),
1362 )
1363 } else {
1364 div()
1365 })
1366 .child(
1367 if let (Some(editor), true) = (editor, show_editor) {
1368 div().w_full().child(editor.clone())
1369 } else {
1370 div().child(Label::new(details.filename.clone()))
1371 }
1372 .ml_1(),
1373 )
1374 .pl(padding)
1375 }
1376
1377 fn render_entry(
1378 &self,
1379 entry_id: ProjectEntryId,
1380 details: EntryDetails,
1381 // dragged_entry_destination: &mut Option<Arc<Path>>,
1382 cx: &mut ViewContext<Self>,
1383 ) -> Div<Self, StatefulInteractivity<Self>> {
1384 let kind = details.kind;
1385 let settings = ProjectPanelSettings::get_global(cx);
1386 const INDENT_SIZE: Pixels = px(16.0);
1387 let padding = INDENT_SIZE + details.depth as f32 * px(settings.indent_size);
1388 let show_editor = details.is_editing && !details.is_processing;
1389 let is_selected = self
1390 .selection
1391 .map_or(false, |selection| selection.entry_id == entry_id);
1392
1393 Self::render_entry_visual_element(&details, Some(&self.filename_editor), padding, cx)
1394 .id(entry_id.to_proto() as usize)
1395 .w_full()
1396 .cursor_pointer()
1397 .when(is_selected, |this| {
1398 this.bg(cx.theme().colors().element_selected)
1399 })
1400 .hover(|style| style.bg(cx.theme().colors().element_hover))
1401 .on_click(move |this, event, cx| {
1402 if !show_editor {
1403 if kind.is_dir() {
1404 this.toggle_expanded(entry_id, cx);
1405 } else {
1406 if event.down.modifiers.command {
1407 this.split_entry(entry_id, cx);
1408 } else {
1409 this.open_entry(entry_id, event.up.click_count > 1, cx);
1410 }
1411 }
1412 }
1413 })
1414 // .on_down(MouseButton::Right, move |event, this, cx| {
1415 // this.deploy_context_menu(event.position, entry_id, cx);
1416 // })
1417 // .on_up(MouseButton::Left, move |_, this, cx| {
1418 // if let Some((_, dragged_entry)) = cx
1419 // .global::<DragAndDrop<Workspace>>()
1420 // .currently_dragged::<ProjectEntryId>(cx.window())
1421 // {
1422 // this.move_entry(
1423 // *dragged_entry,
1424 // entry_id,
1425 // matches!(details.kind, EntryKind::File(_)),
1426 // cx,
1427 // );
1428 // }
1429 // })
1430 }
1431}
1432
1433impl Render for ProjectPanel {
1434 type Element = Div<Self, StatefulInteractivity<Self>, FocusableKeyDispatch<Self>>;
1435
1436 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1437 let theme = cx.theme();
1438 let last_worktree_root_id = self.last_worktree_root_id;
1439
1440 let has_worktree = self.visible_entries.len() != 0;
1441
1442 if has_worktree {
1443 div()
1444 .id("project-panel")
1445 .size_full()
1446 .context("ProjectPanel")
1447 .on_action(Self::select_next)
1448 .on_action(Self::select_prev)
1449 .on_action(Self::expand_selected_entry)
1450 .on_action(Self::collapse_selected_entry)
1451 .on_action(Self::collapse_all_entries)
1452 .on_action(Self::new_file)
1453 .on_action(Self::new_directory)
1454 .on_action(Self::rename)
1455 .on_action(Self::delete)
1456 .on_action(Self::confirm)
1457 .on_action(Self::open_file)
1458 .on_action(Self::cancel)
1459 .on_action(Self::cut)
1460 .on_action(Self::copy)
1461 .on_action(Self::copy_path)
1462 .on_action(Self::copy_relative_path)
1463 .on_action(Self::paste)
1464 .on_action(Self::reveal_in_finder)
1465 .on_action(Self::open_in_terminal)
1466 .on_action(Self::new_search_in_directory)
1467 .track_focus(&self.focus_handle)
1468 .child(
1469 uniform_list(
1470 "entries",
1471 self.visible_entries
1472 .iter()
1473 .map(|(_, worktree_entries)| worktree_entries.len())
1474 .sum(),
1475 |this: &mut Self, range, cx| {
1476 let mut items = SmallVec::new();
1477 this.for_each_visible_entry(range, cx, |id, details, cx| {
1478 items.push(this.render_entry(
1479 id, details, // &mut dragged_entry_destination,
1480 cx,
1481 ));
1482 });
1483 items
1484 },
1485 )
1486 .size_full()
1487 .track_scroll(self.list.clone()),
1488 )
1489 } else {
1490 v_stack()
1491 .id("empty-project_panel")
1492 .track_focus(&self.focus_handle)
1493 }
1494 }
1495}
1496
1497impl EventEmitter<Event> for ProjectPanel {}
1498
1499impl EventEmitter<PanelEvent> for ProjectPanel {}
1500
1501impl workspace::dock::Panel for ProjectPanel {
1502 fn position(&self, cx: &WindowContext) -> DockPosition {
1503 match ProjectPanelSettings::get_global(cx).dock {
1504 ProjectPanelDockPosition::Left => DockPosition::Left,
1505 ProjectPanelDockPosition::Right => DockPosition::Right,
1506 }
1507 }
1508
1509 fn position_is_valid(&self, position: DockPosition) -> bool {
1510 matches!(position, DockPosition::Left | DockPosition::Right)
1511 }
1512
1513 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1514 settings::update_settings_file::<ProjectPanelSettings>(
1515 self.fs.clone(),
1516 cx,
1517 move |settings| {
1518 let dock = match position {
1519 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1520 DockPosition::Right => ProjectPanelDockPosition::Right,
1521 };
1522 settings.dock = Some(dock);
1523 },
1524 );
1525 }
1526
1527 fn size(&self, cx: &WindowContext) -> f32 {
1528 self.width
1529 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1530 }
1531
1532 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1533 self.width = size;
1534 self.serialize(cx);
1535 cx.notify();
1536 }
1537
1538 fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1539 Some("icons/project.svg")
1540 }
1541
1542 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1543 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1544 }
1545
1546 // fn should_change_position_on_event(event: &Self::Event) -> bool {
1547 // matches!(event, Event::DockPositionChanged)
1548 // }
1549
1550 fn has_focus(&self, _: &WindowContext) -> bool {
1551 self.has_focus
1552 }
1553
1554 fn persistent_name(&self) -> &'static str {
1555 "Project Panel"
1556 }
1557
1558 fn focus_handle(&self, _cx: &WindowContext) -> FocusHandle {
1559 self.focus_handle.clone()
1560 }
1561
1562 // fn is_focus_event(event: &Self::Event) -> bool {
1563 // matches!(event, Event::Focus)
1564 // }
1565}
1566
1567impl ClipboardEntry {
1568 fn is_cut(&self) -> bool {
1569 matches!(self, Self::Cut { .. })
1570 }
1571
1572 fn entry_id(&self) -> ProjectEntryId {
1573 match self {
1574 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1575 *entry_id
1576 }
1577 }
1578 }
1579
1580 fn worktree_id(&self) -> WorktreeId {
1581 match self {
1582 ClipboardEntry::Copied { worktree_id, .. }
1583 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1584 }
1585 }
1586}
1587
1588// todo!()
1589// #[cfg(test)]
1590// mod tests {
1591// use super::*;
1592// use gpui::{AnyWindowHandle, TestAppContext, View, WindowHandle};
1593// use pretty_assertions::assert_eq;
1594// use project::FakeFs;
1595// use serde_json::json;
1596// use settings::SettingsStore;
1597// use std::{
1598// collections::HashSet,
1599// path::{Path, PathBuf},
1600// sync::atomic::{self, AtomicUsize},
1601// };
1602// use workspace::{pane, AppState};
1603
1604// #[gpui::test]
1605// async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1606// init_test(cx);
1607
1608// let fs = FakeFs::new(cx.executor().clone());
1609// fs.insert_tree(
1610// "/root1",
1611// json!({
1612// ".dockerignore": "",
1613// ".git": {
1614// "HEAD": "",
1615// },
1616// "a": {
1617// "0": { "q": "", "r": "", "s": "" },
1618// "1": { "t": "", "u": "" },
1619// "2": { "v": "", "w": "", "x": "", "y": "" },
1620// },
1621// "b": {
1622// "3": { "Q": "" },
1623// "4": { "R": "", "S": "", "T": "", "U": "" },
1624// },
1625// "C": {
1626// "5": {},
1627// "6": { "V": "", "W": "" },
1628// "7": { "X": "" },
1629// "8": { "Y": {}, "Z": "" }
1630// }
1631// }),
1632// )
1633// .await;
1634// fs.insert_tree(
1635// "/root2",
1636// json!({
1637// "d": {
1638// "9": ""
1639// },
1640// "e": {}
1641// }),
1642// )
1643// .await;
1644
1645// let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1646// let workspace = cx
1647// .add_window(|cx| Workspace::test_new(project.clone(), cx))
1648// .root(cx);
1649// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1650// assert_eq!(
1651// visible_entries_as_strings(&panel, 0..50, cx),
1652// &[
1653// "v root1",
1654// " > .git",
1655// " > a",
1656// " > b",
1657// " > C",
1658// " .dockerignore",
1659// "v root2",
1660// " > d",
1661// " > e",
1662// ]
1663// );
1664
1665// toggle_expand_dir(&panel, "root1/b", cx);
1666// assert_eq!(
1667// visible_entries_as_strings(&panel, 0..50, cx),
1668// &[
1669// "v root1",
1670// " > .git",
1671// " > a",
1672// " v b <== selected",
1673// " > 3",
1674// " > 4",
1675// " > C",
1676// " .dockerignore",
1677// "v root2",
1678// " > d",
1679// " > e",
1680// ]
1681// );
1682
1683// assert_eq!(
1684// visible_entries_as_strings(&panel, 6..9, cx),
1685// &[
1686// //
1687// " > C",
1688// " .dockerignore",
1689// "v root2",
1690// ]
1691// );
1692// }
1693
1694// #[gpui::test(iterations = 30)]
1695// async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1696// init_test(cx);
1697
1698// let fs = FakeFs::new(cx.background());
1699// fs.insert_tree(
1700// "/root1",
1701// json!({
1702// ".dockerignore": "",
1703// ".git": {
1704// "HEAD": "",
1705// },
1706// "a": {
1707// "0": { "q": "", "r": "", "s": "" },
1708// "1": { "t": "", "u": "" },
1709// "2": { "v": "", "w": "", "x": "", "y": "" },
1710// },
1711// "b": {
1712// "3": { "Q": "" },
1713// "4": { "R": "", "S": "", "T": "", "U": "" },
1714// },
1715// "C": {
1716// "5": {},
1717// "6": { "V": "", "W": "" },
1718// "7": { "X": "" },
1719// "8": { "Y": {}, "Z": "" }
1720// }
1721// }),
1722// )
1723// .await;
1724// fs.insert_tree(
1725// "/root2",
1726// json!({
1727// "d": {
1728// "9": ""
1729// },
1730// "e": {}
1731// }),
1732// )
1733// .await;
1734
1735// let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1736// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1737// let workspace = window.root(cx);
1738// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1739
1740// select_path(&panel, "root1", cx);
1741// assert_eq!(
1742// visible_entries_as_strings(&panel, 0..10, cx),
1743// &[
1744// "v root1 <== selected",
1745// " > .git",
1746// " > a",
1747// " > b",
1748// " > C",
1749// " .dockerignore",
1750// "v root2",
1751// " > d",
1752// " > e",
1753// ]
1754// );
1755
1756// // Add a file with the root folder selected. The filename editor is placed
1757// // before the first file in the root folder.
1758// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1759// window.read_with(cx, |cx| {
1760// let panel = panel.read(cx);
1761// assert!(panel.filename_editor.is_focused(cx));
1762// });
1763// assert_eq!(
1764// visible_entries_as_strings(&panel, 0..10, cx),
1765// &[
1766// "v root1",
1767// " > .git",
1768// " > a",
1769// " > b",
1770// " > C",
1771// " [EDITOR: ''] <== selected",
1772// " .dockerignore",
1773// "v root2",
1774// " > d",
1775// " > e",
1776// ]
1777// );
1778
1779// let confirm = panel.update(cx, |panel, cx| {
1780// panel
1781// .filename_editor
1782// .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1783// panel.confirm(&Confirm, cx).unwrap()
1784// });
1785// assert_eq!(
1786// visible_entries_as_strings(&panel, 0..10, cx),
1787// &[
1788// "v root1",
1789// " > .git",
1790// " > a",
1791// " > b",
1792// " > C",
1793// " [PROCESSING: 'the-new-filename'] <== selected",
1794// " .dockerignore",
1795// "v root2",
1796// " > d",
1797// " > e",
1798// ]
1799// );
1800
1801// confirm.await.unwrap();
1802// assert_eq!(
1803// visible_entries_as_strings(&panel, 0..10, cx),
1804// &[
1805// "v root1",
1806// " > .git",
1807// " > a",
1808// " > b",
1809// " > C",
1810// " .dockerignore",
1811// " the-new-filename <== selected",
1812// "v root2",
1813// " > d",
1814// " > e",
1815// ]
1816// );
1817
1818// select_path(&panel, "root1/b", cx);
1819// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1820// assert_eq!(
1821// visible_entries_as_strings(&panel, 0..10, cx),
1822// &[
1823// "v root1",
1824// " > .git",
1825// " > a",
1826// " v b",
1827// " > 3",
1828// " > 4",
1829// " [EDITOR: ''] <== selected",
1830// " > C",
1831// " .dockerignore",
1832// " the-new-filename",
1833// ]
1834// );
1835
1836// panel
1837// .update(cx, |panel, cx| {
1838// panel
1839// .filename_editor
1840// .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1841// panel.confirm(&Confirm, cx).unwrap()
1842// })
1843// .await
1844// .unwrap();
1845// assert_eq!(
1846// visible_entries_as_strings(&panel, 0..10, cx),
1847// &[
1848// "v root1",
1849// " > .git",
1850// " > a",
1851// " v b",
1852// " > 3",
1853// " > 4",
1854// " another-filename.txt <== selected",
1855// " > C",
1856// " .dockerignore",
1857// " the-new-filename",
1858// ]
1859// );
1860
1861// select_path(&panel, "root1/b/another-filename.txt", cx);
1862// panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1863// assert_eq!(
1864// visible_entries_as_strings(&panel, 0..10, cx),
1865// &[
1866// "v root1",
1867// " > .git",
1868// " > a",
1869// " v b",
1870// " > 3",
1871// " > 4",
1872// " [EDITOR: 'another-filename.txt'] <== selected",
1873// " > C",
1874// " .dockerignore",
1875// " the-new-filename",
1876// ]
1877// );
1878
1879// let confirm = panel.update(cx, |panel, cx| {
1880// panel.filename_editor.update(cx, |editor, cx| {
1881// let file_name_selections = editor.selections.all::<usize>(cx);
1882// assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1883// let file_name_selection = &file_name_selections[0];
1884// assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1885// assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1886
1887// editor.set_text("a-different-filename.tar.gz", cx)
1888// });
1889// panel.confirm(&Confirm, cx).unwrap()
1890// });
1891// assert_eq!(
1892// visible_entries_as_strings(&panel, 0..10, cx),
1893// &[
1894// "v root1",
1895// " > .git",
1896// " > a",
1897// " v b",
1898// " > 3",
1899// " > 4",
1900// " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
1901// " > C",
1902// " .dockerignore",
1903// " the-new-filename",
1904// ]
1905// );
1906
1907// confirm.await.unwrap();
1908// assert_eq!(
1909// visible_entries_as_strings(&panel, 0..10, cx),
1910// &[
1911// "v root1",
1912// " > .git",
1913// " > a",
1914// " v b",
1915// " > 3",
1916// " > 4",
1917// " a-different-filename.tar.gz <== selected",
1918// " > C",
1919// " .dockerignore",
1920// " the-new-filename",
1921// ]
1922// );
1923
1924// panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1925// assert_eq!(
1926// visible_entries_as_strings(&panel, 0..10, cx),
1927// &[
1928// "v root1",
1929// " > .git",
1930// " > a",
1931// " v b",
1932// " > 3",
1933// " > 4",
1934// " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
1935// " > C",
1936// " .dockerignore",
1937// " the-new-filename",
1938// ]
1939// );
1940
1941// panel.update(cx, |panel, cx| {
1942// panel.filename_editor.update(cx, |editor, cx| {
1943// let file_name_selections = editor.selections.all::<usize>(cx);
1944// assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1945// let file_name_selection = &file_name_selections[0];
1946// assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1947// 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");
1948
1949// });
1950// panel.cancel(&Cancel, cx)
1951// });
1952
1953// panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1954// assert_eq!(
1955// visible_entries_as_strings(&panel, 0..10, cx),
1956// &[
1957// "v root1",
1958// " > .git",
1959// " > a",
1960// " v b",
1961// " > [EDITOR: ''] <== selected",
1962// " > 3",
1963// " > 4",
1964// " a-different-filename.tar.gz",
1965// " > C",
1966// " .dockerignore",
1967// ]
1968// );
1969
1970// let confirm = panel.update(cx, |panel, cx| {
1971// panel
1972// .filename_editor
1973// .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1974// panel.confirm(&Confirm, cx).unwrap()
1975// });
1976// panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1977// assert_eq!(
1978// visible_entries_as_strings(&panel, 0..10, cx),
1979// &[
1980// "v root1",
1981// " > .git",
1982// " > a",
1983// " v b",
1984// " > [PROCESSING: 'new-dir']",
1985// " > 3 <== selected",
1986// " > 4",
1987// " a-different-filename.tar.gz",
1988// " > C",
1989// " .dockerignore",
1990// ]
1991// );
1992
1993// confirm.await.unwrap();
1994// assert_eq!(
1995// visible_entries_as_strings(&panel, 0..10, cx),
1996// &[
1997// "v root1",
1998// " > .git",
1999// " > a",
2000// " v b",
2001// " > 3 <== selected",
2002// " > 4",
2003// " > new-dir",
2004// " a-different-filename.tar.gz",
2005// " > C",
2006// " .dockerignore",
2007// ]
2008// );
2009
2010// panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2011// assert_eq!(
2012// visible_entries_as_strings(&panel, 0..10, cx),
2013// &[
2014// "v root1",
2015// " > .git",
2016// " > a",
2017// " v b",
2018// " > [EDITOR: '3'] <== selected",
2019// " > 4",
2020// " > new-dir",
2021// " a-different-filename.tar.gz",
2022// " > C",
2023// " .dockerignore",
2024// ]
2025// );
2026
2027// // Dismiss the rename editor when it loses focus.
2028// workspace.update(cx, |_, cx| cx.focus_self());
2029// assert_eq!(
2030// visible_entries_as_strings(&panel, 0..10, cx),
2031// &[
2032// "v root1",
2033// " > .git",
2034// " > a",
2035// " v b",
2036// " > 3 <== selected",
2037// " > 4",
2038// " > new-dir",
2039// " a-different-filename.tar.gz",
2040// " > C",
2041// " .dockerignore",
2042// ]
2043// );
2044// }
2045
2046// #[gpui::test(iterations = 30)]
2047// async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2048// init_test(cx);
2049
2050// let fs = FakeFs::new(cx.background());
2051// fs.insert_tree(
2052// "/root1",
2053// json!({
2054// ".dockerignore": "",
2055// ".git": {
2056// "HEAD": "",
2057// },
2058// "a": {
2059// "0": { "q": "", "r": "", "s": "" },
2060// "1": { "t": "", "u": "" },
2061// "2": { "v": "", "w": "", "x": "", "y": "" },
2062// },
2063// "b": {
2064// "3": { "Q": "" },
2065// "4": { "R": "", "S": "", "T": "", "U": "" },
2066// },
2067// "C": {
2068// "5": {},
2069// "6": { "V": "", "W": "" },
2070// "7": { "X": "" },
2071// "8": { "Y": {}, "Z": "" }
2072// }
2073// }),
2074// )
2075// .await;
2076// fs.insert_tree(
2077// "/root2",
2078// json!({
2079// "d": {
2080// "9": ""
2081// },
2082// "e": {}
2083// }),
2084// )
2085// .await;
2086
2087// let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2088// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2089// let workspace = window.root(cx);
2090// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2091
2092// select_path(&panel, "root1", cx);
2093// assert_eq!(
2094// visible_entries_as_strings(&panel, 0..10, cx),
2095// &[
2096// "v root1 <== selected",
2097// " > .git",
2098// " > a",
2099// " > b",
2100// " > C",
2101// " .dockerignore",
2102// "v root2",
2103// " > d",
2104// " > e",
2105// ]
2106// );
2107
2108// // Add a file with the root folder selected. The filename editor is placed
2109// // before the first file in the root folder.
2110// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2111// window.read_with(cx, |cx| {
2112// let panel = panel.read(cx);
2113// assert!(panel.filename_editor.is_focused(cx));
2114// });
2115// assert_eq!(
2116// visible_entries_as_strings(&panel, 0..10, cx),
2117// &[
2118// "v root1",
2119// " > .git",
2120// " > a",
2121// " > b",
2122// " > C",
2123// " [EDITOR: ''] <== selected",
2124// " .dockerignore",
2125// "v root2",
2126// " > d",
2127// " > e",
2128// ]
2129// );
2130
2131// let confirm = panel.update(cx, |panel, cx| {
2132// panel.filename_editor.update(cx, |editor, cx| {
2133// editor.set_text("/bdir1/dir2/the-new-filename", cx)
2134// });
2135// panel.confirm(&Confirm, cx).unwrap()
2136// });
2137
2138// assert_eq!(
2139// visible_entries_as_strings(&panel, 0..10, cx),
2140// &[
2141// "v root1",
2142// " > .git",
2143// " > a",
2144// " > b",
2145// " > C",
2146// " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2147// " .dockerignore",
2148// "v root2",
2149// " > d",
2150// " > e",
2151// ]
2152// );
2153
2154// confirm.await.unwrap();
2155// assert_eq!(
2156// visible_entries_as_strings(&panel, 0..13, cx),
2157// &[
2158// "v root1",
2159// " > .git",
2160// " > a",
2161// " > b",
2162// " v bdir1",
2163// " v dir2",
2164// " the-new-filename <== selected",
2165// " > C",
2166// " .dockerignore",
2167// "v root2",
2168// " > d",
2169// " > e",
2170// ]
2171// );
2172// }
2173
2174// #[gpui::test]
2175// async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2176// init_test(cx);
2177
2178// let fs = FakeFs::new(cx.background());
2179// fs.insert_tree(
2180// "/root1",
2181// json!({
2182// "one.two.txt": "",
2183// "one.txt": ""
2184// }),
2185// )
2186// .await;
2187
2188// let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2189// let workspace = cx
2190// .add_window(|cx| Workspace::test_new(project.clone(), cx))
2191// .root(cx);
2192// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2193
2194// panel.update(cx, |panel, cx| {
2195// panel.select_next(&Default::default(), cx);
2196// panel.select_next(&Default::default(), cx);
2197// });
2198
2199// assert_eq!(
2200// visible_entries_as_strings(&panel, 0..50, cx),
2201// &[
2202// //
2203// "v root1",
2204// " one.two.txt <== selected",
2205// " one.txt",
2206// ]
2207// );
2208
2209// // Regression test - file name is created correctly when
2210// // the copied file's name contains multiple dots.
2211// panel.update(cx, |panel, cx| {
2212// panel.copy(&Default::default(), cx);
2213// panel.paste(&Default::default(), cx);
2214// });
2215// cx.foreground().run_until_parked();
2216
2217// assert_eq!(
2218// visible_entries_as_strings(&panel, 0..50, cx),
2219// &[
2220// //
2221// "v root1",
2222// " one.two copy.txt",
2223// " one.two.txt <== selected",
2224// " one.txt",
2225// ]
2226// );
2227
2228// panel.update(cx, |panel, cx| {
2229// panel.paste(&Default::default(), cx);
2230// });
2231// cx.foreground().run_until_parked();
2232
2233// assert_eq!(
2234// visible_entries_as_strings(&panel, 0..50, cx),
2235// &[
2236// //
2237// "v root1",
2238// " one.two copy 1.txt",
2239// " one.two copy.txt",
2240// " one.two.txt <== selected",
2241// " one.txt",
2242// ]
2243// );
2244// }
2245
2246// #[gpui::test]
2247// async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2248// init_test_with_editor(cx);
2249
2250// let fs = FakeFs::new(cx.background());
2251// fs.insert_tree(
2252// "/src",
2253// json!({
2254// "test": {
2255// "first.rs": "// First Rust file",
2256// "second.rs": "// Second Rust file",
2257// "third.rs": "// Third Rust file",
2258// }
2259// }),
2260// )
2261// .await;
2262
2263// let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2264// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2265// let workspace = window.root(cx);
2266// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2267
2268// toggle_expand_dir(&panel, "src/test", cx);
2269// select_path(&panel, "src/test/first.rs", cx);
2270// panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2271// cx.foreground().run_until_parked();
2272// assert_eq!(
2273// visible_entries_as_strings(&panel, 0..10, cx),
2274// &[
2275// "v src",
2276// " v test",
2277// " first.rs <== selected",
2278// " second.rs",
2279// " third.rs"
2280// ]
2281// );
2282// ensure_single_file_is_opened(window, "test/first.rs", cx);
2283
2284// submit_deletion(window.into(), &panel, cx);
2285// assert_eq!(
2286// visible_entries_as_strings(&panel, 0..10, cx),
2287// &[
2288// "v src",
2289// " v test",
2290// " second.rs",
2291// " third.rs"
2292// ],
2293// "Project panel should have no deleted file, no other file is selected in it"
2294// );
2295// ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2296
2297// select_path(&panel, "src/test/second.rs", cx);
2298// panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2299// cx.foreground().run_until_parked();
2300// assert_eq!(
2301// visible_entries_as_strings(&panel, 0..10, cx),
2302// &[
2303// "v src",
2304// " v test",
2305// " second.rs <== selected",
2306// " third.rs"
2307// ]
2308// );
2309// ensure_single_file_is_opened(window, "test/second.rs", cx);
2310
2311// window.update(cx, |cx| {
2312// let active_items = workspace
2313// .read(cx)
2314// .panes()
2315// .iter()
2316// .filter_map(|pane| pane.read(cx).active_item())
2317// .collect::<Vec<_>>();
2318// assert_eq!(active_items.len(), 1);
2319// let open_editor = active_items
2320// .into_iter()
2321// .next()
2322// .unwrap()
2323// .downcast::<Editor>()
2324// .expect("Open item should be an editor");
2325// open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2326// });
2327// submit_deletion(window.into(), &panel, cx);
2328// assert_eq!(
2329// visible_entries_as_strings(&panel, 0..10, cx),
2330// &["v src", " v test", " third.rs"],
2331// "Project panel should have no deleted file, with one last file remaining"
2332// );
2333// ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2334// }
2335
2336// #[gpui::test]
2337// async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2338// init_test_with_editor(cx);
2339
2340// let fs = FakeFs::new(cx.background());
2341// fs.insert_tree(
2342// "/src",
2343// json!({
2344// "test": {
2345// "first.rs": "// First Rust file",
2346// "second.rs": "// Second Rust file",
2347// "third.rs": "// Third Rust file",
2348// }
2349// }),
2350// )
2351// .await;
2352
2353// let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2354// let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2355// let workspace = window.root(cx);
2356// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2357
2358// select_path(&panel, "src/", cx);
2359// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2360// cx.foreground().run_until_parked();
2361// assert_eq!(
2362// visible_entries_as_strings(&panel, 0..10, cx),
2363// &["v src <== selected", " > test"]
2364// );
2365// panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2366// window.read_with(cx, |cx| {
2367// let panel = panel.read(cx);
2368// assert!(panel.filename_editor.is_focused(cx));
2369// });
2370// assert_eq!(
2371// visible_entries_as_strings(&panel, 0..10, cx),
2372// &["v src", " > [EDITOR: ''] <== selected", " > test"]
2373// );
2374// panel.update(cx, |panel, cx| {
2375// panel
2376// .filename_editor
2377// .update(cx, |editor, cx| editor.set_text("test", cx));
2378// assert!(
2379// panel.confirm(&Confirm, cx).is_none(),
2380// "Should not allow to confirm on conflicting new directory name"
2381// )
2382// });
2383// assert_eq!(
2384// visible_entries_as_strings(&panel, 0..10, cx),
2385// &["v src", " > test"],
2386// "File list should be unchanged after failed folder create confirmation"
2387// );
2388
2389// select_path(&panel, "src/test/", cx);
2390// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2391// cx.foreground().run_until_parked();
2392// assert_eq!(
2393// visible_entries_as_strings(&panel, 0..10, cx),
2394// &["v src", " > test <== selected"]
2395// );
2396// panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2397// window.read_with(cx, |cx| {
2398// let panel = panel.read(cx);
2399// assert!(panel.filename_editor.is_focused(cx));
2400// });
2401// assert_eq!(
2402// visible_entries_as_strings(&panel, 0..10, cx),
2403// &[
2404// "v src",
2405// " v test",
2406// " [EDITOR: ''] <== selected",
2407// " first.rs",
2408// " second.rs",
2409// " third.rs"
2410// ]
2411// );
2412// panel.update(cx, |panel, cx| {
2413// panel
2414// .filename_editor
2415// .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2416// assert!(
2417// panel.confirm(&Confirm, cx).is_none(),
2418// "Should not allow to confirm on conflicting new file name"
2419// )
2420// });
2421// assert_eq!(
2422// visible_entries_as_strings(&panel, 0..10, cx),
2423// &[
2424// "v src",
2425// " v test",
2426// " first.rs",
2427// " second.rs",
2428// " third.rs"
2429// ],
2430// "File list should be unchanged after failed file create confirmation"
2431// );
2432
2433// select_path(&panel, "src/test/first.rs", cx);
2434// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2435// cx.foreground().run_until_parked();
2436// assert_eq!(
2437// visible_entries_as_strings(&panel, 0..10, cx),
2438// &[
2439// "v src",
2440// " v test",
2441// " first.rs <== selected",
2442// " second.rs",
2443// " third.rs"
2444// ],
2445// );
2446// panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2447// window.read_with(cx, |cx| {
2448// let panel = panel.read(cx);
2449// assert!(panel.filename_editor.is_focused(cx));
2450// });
2451// assert_eq!(
2452// visible_entries_as_strings(&panel, 0..10, cx),
2453// &[
2454// "v src",
2455// " v test",
2456// " [EDITOR: 'first.rs'] <== selected",
2457// " second.rs",
2458// " third.rs"
2459// ]
2460// );
2461// panel.update(cx, |panel, cx| {
2462// panel
2463// .filename_editor
2464// .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2465// assert!(
2466// panel.confirm(&Confirm, cx).is_none(),
2467// "Should not allow to confirm on conflicting file rename"
2468// )
2469// });
2470// assert_eq!(
2471// visible_entries_as_strings(&panel, 0..10, cx),
2472// &[
2473// "v src",
2474// " v test",
2475// " first.rs <== selected",
2476// " second.rs",
2477// " third.rs"
2478// ],
2479// "File list should be unchanged after failed rename confirmation"
2480// );
2481// }
2482
2483// #[gpui::test]
2484// async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2485// init_test_with_editor(cx);
2486
2487// let fs = FakeFs::new(cx.background());
2488// fs.insert_tree(
2489// "/src",
2490// json!({
2491// "test": {
2492// "first.rs": "// First Rust file",
2493// "second.rs": "// Second Rust file",
2494// "third.rs": "// Third Rust file",
2495// }
2496// }),
2497// )
2498// .await;
2499
2500// let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2501// let workspace = cx
2502// .add_window(|cx| Workspace::test_new(project.clone(), cx))
2503// .root(cx);
2504// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2505
2506// let new_search_events_count = Arc::new(AtomicUsize::new(0));
2507// let _subscription = panel.update(cx, |_, cx| {
2508// let subcription_count = Arc::clone(&new_search_events_count);
2509// cx.subscribe(&cx.handle(), move |_, _, event, _| {
2510// if matches!(event, Event::NewSearchInDirectory { .. }) {
2511// subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2512// }
2513// })
2514// });
2515
2516// toggle_expand_dir(&panel, "src/test", cx);
2517// select_path(&panel, "src/test/first.rs", cx);
2518// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2519// cx.foreground().run_until_parked();
2520// assert_eq!(
2521// visible_entries_as_strings(&panel, 0..10, cx),
2522// &[
2523// "v src",
2524// " v test",
2525// " first.rs <== selected",
2526// " second.rs",
2527// " third.rs"
2528// ]
2529// );
2530// panel.update(cx, |panel, cx| {
2531// panel.new_search_in_directory(&NewSearchInDirectory, cx)
2532// });
2533// assert_eq!(
2534// new_search_events_count.load(atomic::Ordering::SeqCst),
2535// 0,
2536// "Should not trigger new search in directory when called on a file"
2537// );
2538
2539// select_path(&panel, "src/test", cx);
2540// panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2541// cx.foreground().run_until_parked();
2542// assert_eq!(
2543// visible_entries_as_strings(&panel, 0..10, cx),
2544// &[
2545// "v src",
2546// " v test <== selected",
2547// " first.rs",
2548// " second.rs",
2549// " third.rs"
2550// ]
2551// );
2552// panel.update(cx, |panel, cx| {
2553// panel.new_search_in_directory(&NewSearchInDirectory, cx)
2554// });
2555// assert_eq!(
2556// new_search_events_count.load(atomic::Ordering::SeqCst),
2557// 1,
2558// "Should trigger new search in directory when called on a directory"
2559// );
2560// }
2561
2562// #[gpui::test]
2563// async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2564// init_test_with_editor(cx);
2565
2566// let fs = FakeFs::new(cx.background());
2567// fs.insert_tree(
2568// "/project_root",
2569// json!({
2570// "dir_1": {
2571// "nested_dir": {
2572// "file_a.py": "# File contents",
2573// "file_b.py": "# File contents",
2574// "file_c.py": "# File contents",
2575// },
2576// "file_1.py": "# File contents",
2577// "file_2.py": "# File contents",
2578// "file_3.py": "# File contents",
2579// },
2580// "dir_2": {
2581// "file_1.py": "# File contents",
2582// "file_2.py": "# File contents",
2583// "file_3.py": "# File contents",
2584// }
2585// }),
2586// )
2587// .await;
2588
2589// let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2590// let workspace = cx
2591// .add_window(|cx| Workspace::test_new(project.clone(), cx))
2592// .root(cx);
2593// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2594
2595// panel.update(cx, |panel, cx| {
2596// panel.collapse_all_entries(&CollapseAllEntries, cx)
2597// });
2598// cx.foreground().run_until_parked();
2599// assert_eq!(
2600// visible_entries_as_strings(&panel, 0..10, cx),
2601// &["v project_root", " > dir_1", " > dir_2",]
2602// );
2603
2604// // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2605// toggle_expand_dir(&panel, "project_root/dir_1", cx);
2606// cx.foreground().run_until_parked();
2607// assert_eq!(
2608// visible_entries_as_strings(&panel, 0..10, cx),
2609// &[
2610// "v project_root",
2611// " v dir_1 <== selected",
2612// " > nested_dir",
2613// " file_1.py",
2614// " file_2.py",
2615// " file_3.py",
2616// " > dir_2",
2617// ]
2618// );
2619// }
2620
2621// #[gpui::test]
2622// async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2623// init_test(cx);
2624
2625// let fs = FakeFs::new(cx.background());
2626// fs.as_fake().insert_tree("/root", json!({})).await;
2627// let project = Project::test(fs, ["/root".as_ref()], cx).await;
2628// let workspace = cx
2629// .add_window(|cx| Workspace::test_new(project.clone(), cx))
2630// .root(cx);
2631// let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2632
2633// // Make a new buffer with no backing file
2634// workspace.update(cx, |workspace, cx| {
2635// Editor::new_file(workspace, &Default::default(), cx)
2636// });
2637
2638// // "Save as"" the buffer, creating a new backing file for it
2639// let task = workspace.update(cx, |workspace, cx| {
2640// workspace.save_active_item(workspace::SaveIntent::Save, cx)
2641// });
2642
2643// cx.foreground().run_until_parked();
2644// cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2645// task.await.unwrap();
2646
2647// // Rename the file
2648// select_path(&panel, "root/new", cx);
2649// assert_eq!(
2650// visible_entries_as_strings(&panel, 0..10, cx),
2651// &["v root", " new <== selected"]
2652// );
2653// panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2654// panel.update(cx, |panel, cx| {
2655// panel
2656// .filename_editor
2657// .update(cx, |editor, cx| editor.set_text("newer", cx));
2658// });
2659// panel
2660// .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2661// .unwrap()
2662// .await
2663// .unwrap();
2664
2665// cx.foreground().run_until_parked();
2666// assert_eq!(
2667// visible_entries_as_strings(&panel, 0..10, cx),
2668// &["v root", " newer <== selected"]
2669// );
2670
2671// workspace
2672// .update(cx, |workspace, cx| {
2673// workspace.save_active_item(workspace::SaveIntent::Save, cx)
2674// })
2675// .await
2676// .unwrap();
2677
2678// cx.foreground().run_until_parked();
2679// // assert that saving the file doesn't restore "new"
2680// assert_eq!(
2681// visible_entries_as_strings(&panel, 0..10, cx),
2682// &["v root", " newer <== selected"]
2683// );
2684// }
2685
2686// fn toggle_expand_dir(
2687// panel: &View<ProjectPanel>,
2688// path: impl AsRef<Path>,
2689// cx: &mut TestAppContext,
2690// ) {
2691// let path = path.as_ref();
2692// panel.update(cx, |panel, cx| {
2693// for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2694// let worktree = worktree.read(cx);
2695// if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2696// let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2697// panel.toggle_expanded(entry_id, cx);
2698// return;
2699// }
2700// }
2701// panic!("no worktree for path {:?}", path);
2702// });
2703// }
2704
2705// fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut TestAppContext) {
2706// let path = path.as_ref();
2707// panel.update(cx, |panel, cx| {
2708// for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2709// let worktree = worktree.read(cx);
2710// if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2711// let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2712// panel.selection = Some(Selection {
2713// worktree_id: worktree.id(),
2714// entry_id,
2715// });
2716// return;
2717// }
2718// }
2719// panic!("no worktree for path {:?}", path);
2720// });
2721// }
2722
2723// fn visible_entries_as_strings(
2724// panel: &View<ProjectPanel>,
2725// range: Range<usize>,
2726// cx: &mut TestAppContext,
2727// ) -> Vec<String> {
2728// let mut result = Vec::new();
2729// let mut project_entries = HashSet::new();
2730// let mut has_editor = false;
2731
2732// panel.update(cx, |panel, cx| {
2733// panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2734// if details.is_editing {
2735// assert!(!has_editor, "duplicate editor entry");
2736// has_editor = true;
2737// } else {
2738// assert!(
2739// project_entries.insert(project_entry),
2740// "duplicate project entry {:?} {:?}",
2741// project_entry,
2742// details
2743// );
2744// }
2745
2746// let indent = " ".repeat(details.depth);
2747// let icon = if details.kind.is_dir() {
2748// if details.is_expanded {
2749// "v "
2750// } else {
2751// "> "
2752// }
2753// } else {
2754// " "
2755// };
2756// let name = if details.is_editing {
2757// format!("[EDITOR: '{}']", details.filename)
2758// } else if details.is_processing {
2759// format!("[PROCESSING: '{}']", details.filename)
2760// } else {
2761// details.filename.clone()
2762// };
2763// let selected = if details.is_selected {
2764// " <== selected"
2765// } else {
2766// ""
2767// };
2768// result.push(format!("{indent}{icon}{name}{selected}"));
2769// });
2770// });
2771
2772// result
2773// }
2774
2775// fn init_test(cx: &mut TestAppContext) {
2776// cx.foreground().forbid_parking();
2777// cx.update(|cx| {
2778// cx.set_global(SettingsStore::test(cx));
2779// init_settings(cx);
2780// theme::init(cx);
2781// language::init(cx);
2782// editor::init_settings(cx);
2783// crate::init((), cx);
2784// workspace::init_settings(cx);
2785// client::init_settings(cx);
2786// Project::init_settings(cx);
2787// });
2788// }
2789
2790// fn init_test_with_editor(cx: &mut TestAppContext) {
2791// cx.foreground().forbid_parking();
2792// cx.update(|cx| {
2793// let app_state = AppState::test(cx);
2794// theme::init(cx);
2795// init_settings(cx);
2796// language::init(cx);
2797// editor::init(cx);
2798// pane::init(cx);
2799// crate::init((), cx);
2800// workspace::init(app_state.clone(), cx);
2801// Project::init_settings(cx);
2802// });
2803// }
2804
2805// fn ensure_single_file_is_opened(
2806// window: WindowHandle<Workspace>,
2807// expected_path: &str,
2808// cx: &mut TestAppContext,
2809// ) {
2810// window.update_root(cx, |workspace, cx| {
2811// let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2812// assert_eq!(worktrees.len(), 1);
2813// let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2814
2815// let open_project_paths = workspace
2816// .panes()
2817// .iter()
2818// .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2819// .collect::<Vec<_>>();
2820// assert_eq!(
2821// open_project_paths,
2822// vec![ProjectPath {
2823// worktree_id,
2824// path: Arc::from(Path::new(expected_path))
2825// }],
2826// "Should have opened file, selected in project panel"
2827// );
2828// });
2829// }
2830
2831// fn submit_deletion(
2832// window: AnyWindowHandle,
2833// panel: &View<ProjectPanel>,
2834// cx: &mut TestAppContext,
2835// ) {
2836// assert!(
2837// !window.has_pending_prompt(cx),
2838// "Should have no prompts before the deletion"
2839// );
2840// panel.update(cx, |panel, cx| {
2841// panel
2842// .delete(&Delete, cx)
2843// .expect("Deletion start")
2844// .detach_and_log_err(cx);
2845// });
2846// assert!(
2847// window.has_pending_prompt(cx),
2848// "Should have a prompt after the deletion"
2849// );
2850// window.simulate_prompt_answer(0, cx);
2851// assert!(
2852// !window.has_pending_prompt(cx),
2853// "Should have no prompts after prompt was replied to"
2854// );
2855// cx.foreground().run_until_parked();
2856// }
2857
2858// fn ensure_no_open_items_and_panes(
2859// window: AnyWindowHandle,
2860// workspace: &View<Workspace>,
2861// cx: &mut TestAppContext,
2862// ) {
2863// assert!(
2864// !window.has_pending_prompt(cx),
2865// "Should have no prompts after deletion operation closes the file"
2866// );
2867// window.read_with(cx, |cx| {
2868// let open_project_paths = workspace
2869// .read(cx)
2870// .panes()
2871// .iter()
2872// .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2873// .collect::<Vec<_>>();
2874// assert!(
2875// open_project_paths.is_empty(),
2876// "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2877// );
2878// });
2879// }
2880// }