1mod project_panel_settings;
2mod utils;
3
4use anyhow::{Context as _, Result, anyhow};
5use client::{ErrorCode, ErrorExt};
6use collections::{BTreeSet, HashMap, hash_map};
7use command_palette_hooks::CommandPaletteFilter;
8use db::kvp::KEY_VALUE_STORE;
9use editor::{
10 Editor, EditorEvent, EditorSettings, ShowScrollbar,
11 items::{
12 entry_diagnostic_aware_icon_decoration_and_color,
13 entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
14 },
15 scroll::{Autoscroll, ScrollbarAutoHide},
16};
17use file_icons::FileIcons;
18use git::status::GitSummary;
19use gpui::{
20 Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
21 DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable,
22 Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
23 MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
24 Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
25 anchored, deferred, div, impl_actions, point, px, size, uniform_list,
26};
27use indexmap::IndexMap;
28use language::DiagnosticSeverity;
29use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
30use project::{
31 Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
32 ProjectPath, Worktree, WorktreeId,
33 git_store::{GitStoreEvent, git_traversal::ChildEntriesGitIter},
34 relativize_path,
35};
36use project_panel_settings::{
37 ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides,
38};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use settings::{Settings, SettingsStore, update_settings_file};
42use smallvec::SmallVec;
43use std::any::TypeId;
44use std::{
45 cell::OnceCell,
46 cmp,
47 collections::HashSet,
48 ffi::OsStr,
49 ops::Range,
50 path::{Path, PathBuf},
51 sync::Arc,
52 time::Duration,
53};
54use theme::ThemeSettings;
55use ui::{
56 Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
57 IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar,
58 ScrollbarState, Tooltip, prelude::*, v_flex,
59};
60use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths};
61use workspace::{
62 DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry,
63 Workspace,
64 dock::{DockPosition, Panel, PanelEvent},
65 notifications::{DetachAndPromptErr, NotifyTaskExt},
66};
67use worktree::CreatedEntry;
68
69const PROJECT_PANEL_KEY: &str = "ProjectPanel";
70const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
71
72pub struct ProjectPanel {
73 project: Entity<Project>,
74 fs: Arc<dyn Fs>,
75 focus_handle: FocusHandle,
76 scroll_handle: UniformListScrollHandle,
77 // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
78 // hovered over the start/end of a list.
79 hover_scroll_task: Option<Task<()>>,
80 visible_entries: Vec<(WorktreeId, Vec<GitEntry>, OnceCell<HashSet<Arc<Path>>>)>,
81 /// Maps from leaf project entry ID to the currently selected ancestor.
82 /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
83 /// project entries (and all non-leaf nodes are guaranteed to be directories).
84 ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
85 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
86 last_worktree_root_id: Option<ProjectEntryId>,
87 last_selection_drag_over_entry: Option<ProjectEntryId>,
88 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
89 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
90 unfolded_dir_ids: HashSet<ProjectEntryId>,
91 // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
92 selection: Option<SelectedEntry>,
93 marked_entries: BTreeSet<SelectedEntry>,
94 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
95 edit_state: Option<EditState>,
96 filename_editor: Entity<Editor>,
97 clipboard: Option<ClipboardEntry>,
98 _dragged_entry_destination: Option<Arc<Path>>,
99 workspace: WeakEntity<Workspace>,
100 width: Option<Pixels>,
101 pending_serialization: Task<Option<()>>,
102 show_scrollbar: bool,
103 vertical_scrollbar_state: ScrollbarState,
104 horizontal_scrollbar_state: ScrollbarState,
105 hide_scrollbar_task: Option<Task<()>>,
106 diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
107 max_width_item_index: Option<usize>,
108 // We keep track of the mouse down state on entries so we don't flash the UI
109 // in case a user clicks to open a file.
110 mouse_down: bool,
111 hover_expand_task: Option<Task<()>>,
112}
113
114#[derive(Copy, Clone, Debug)]
115struct FoldedDirectoryDragTarget {
116 entry_id: ProjectEntryId,
117 index: usize,
118 /// Whether we are dragging over the delimiter rather than the component itself.
119 is_delimiter_target: bool,
120}
121
122#[derive(Clone, Debug)]
123enum ValidationState {
124 None,
125 Warning(String),
126 Error(String),
127}
128
129#[derive(Clone, Debug)]
130struct EditState {
131 worktree_id: WorktreeId,
132 entry_id: ProjectEntryId,
133 leaf_entry_id: Option<ProjectEntryId>,
134 is_dir: bool,
135 depth: usize,
136 processing_filename: Option<String>,
137 previously_focused: Option<SelectedEntry>,
138 validation_state: ValidationState,
139}
140
141impl EditState {
142 fn is_new_entry(&self) -> bool {
143 self.leaf_entry_id.is_none()
144 }
145}
146
147#[derive(Clone, Debug)]
148enum ClipboardEntry {
149 Copied(BTreeSet<SelectedEntry>),
150 Cut(BTreeSet<SelectedEntry>),
151}
152
153#[derive(Debug, PartialEq, Eq, Clone)]
154struct EntryDetails {
155 filename: String,
156 icon: Option<SharedString>,
157 path: Arc<Path>,
158 depth: usize,
159 kind: EntryKind,
160 is_ignored: bool,
161 is_expanded: bool,
162 is_selected: bool,
163 is_marked: bool,
164 is_editing: bool,
165 is_processing: bool,
166 is_cut: bool,
167 filename_text_color: Color,
168 diagnostic_severity: Option<DiagnosticSeverity>,
169 git_status: GitSummary,
170 is_private: bool,
171 worktree_id: WorktreeId,
172 canonical_path: Option<Arc<Path>>,
173}
174
175#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
176#[serde(deny_unknown_fields)]
177struct Delete {
178 #[serde(default)]
179 pub skip_prompt: bool,
180}
181
182#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
183#[serde(deny_unknown_fields)]
184struct Trash {
185 #[serde(default)]
186 pub skip_prompt: bool,
187}
188
189impl_actions!(project_panel, [Delete, Trash]);
190
191actions!(
192 project_panel,
193 [
194 ExpandSelectedEntry,
195 CollapseSelectedEntry,
196 CollapseAllEntries,
197 NewDirectory,
198 NewFile,
199 Copy,
200 Duplicate,
201 RevealInFileManager,
202 RemoveFromProject,
203 OpenWithSystem,
204 Cut,
205 Paste,
206 Rename,
207 Open,
208 OpenPermanent,
209 ToggleFocus,
210 ToggleHideGitIgnore,
211 NewSearchInDirectory,
212 UnfoldDirectory,
213 FoldDirectory,
214 SelectParent,
215 SelectNextGitEntry,
216 SelectPrevGitEntry,
217 SelectNextDiagnostic,
218 SelectPrevDiagnostic,
219 SelectNextDirectory,
220 SelectPrevDirectory,
221 ]
222);
223
224#[derive(Debug, Default)]
225struct FoldedAncestors {
226 current_ancestor_depth: usize,
227 ancestors: Vec<ProjectEntryId>,
228}
229
230impl FoldedAncestors {
231 fn max_ancestor_depth(&self) -> usize {
232 self.ancestors.len()
233 }
234}
235
236pub fn init_settings(cx: &mut App) {
237 ProjectPanelSettings::register(cx);
238}
239
240pub fn init(cx: &mut App) {
241 init_settings(cx);
242
243 cx.observe_new(|workspace: &mut Workspace, _, _| {
244 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
245 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
246 });
247
248 workspace.register_action(|workspace, _: &ToggleHideGitIgnore, _, cx| {
249 let fs = workspace.app_state().fs.clone();
250 update_settings_file::<ProjectPanelSettings>(fs, cx, move |setting, _| {
251 setting.hide_gitignore = Some(!setting.hide_gitignore.unwrap_or(false));
252 })
253 });
254 })
255 .detach();
256}
257
258#[derive(Debug)]
259pub enum Event {
260 OpenedEntry {
261 entry_id: ProjectEntryId,
262 focus_opened_item: bool,
263 allow_preview: bool,
264 },
265 SplitEntry {
266 entry_id: ProjectEntryId,
267 },
268 Focus,
269}
270
271#[derive(Serialize, Deserialize)]
272struct SerializedProjectPanel {
273 width: Option<Pixels>,
274}
275
276struct DraggedProjectEntryView {
277 selection: SelectedEntry,
278 details: EntryDetails,
279 click_offset: Point<Pixels>,
280 selections: Arc<BTreeSet<SelectedEntry>>,
281}
282
283struct ItemColors {
284 default: Hsla,
285 hover: Hsla,
286 drag_over: Hsla,
287 marked: Hsla,
288 focused: Hsla,
289}
290
291fn get_item_color(cx: &App) -> ItemColors {
292 let colors = cx.theme().colors();
293
294 ItemColors {
295 default: colors.panel_background,
296 hover: colors.element_hover,
297 marked: colors.element_selected,
298 focused: colors.panel_focused_border,
299 drag_over: colors.drop_target_background,
300 }
301}
302
303impl ProjectPanel {
304 fn new(
305 workspace: &mut Workspace,
306 window: &mut Window,
307 cx: &mut Context<Workspace>,
308 ) -> Entity<Self> {
309 let project = workspace.project().clone();
310 let git_store = project.read(cx).git_store().clone();
311 let project_panel = cx.new(|cx| {
312 let focus_handle = cx.focus_handle();
313 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
314 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
315 this.focus_out(window, cx);
316 this.hide_scrollbar(window, cx);
317 })
318 .detach();
319
320 cx.subscribe(&git_store, |this, _, event, cx| match event {
321 GitStoreEvent::RepositoryUpdated(_, _, _)
322 | GitStoreEvent::RepositoryAdded(_)
323 | GitStoreEvent::RepositoryRemoved(_) => {
324 this.update_visible_entries(None, cx);
325 cx.notify();
326 }
327 _ => {}
328 })
329 .detach();
330
331 cx.subscribe(&project, |this, project, event, cx| match event {
332 project::Event::ActiveEntryChanged(Some(entry_id)) => {
333 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
334 this.reveal_entry(project.clone(), *entry_id, true, cx);
335 }
336 }
337 project::Event::ActiveEntryChanged(None) => {
338 this.marked_entries.clear();
339 }
340 project::Event::RevealInProjectPanel(entry_id) => {
341 this.reveal_entry(project.clone(), *entry_id, false, cx);
342 cx.emit(PanelEvent::Activate);
343 }
344 project::Event::ActivateProjectPanel => {
345 cx.emit(PanelEvent::Activate);
346 }
347 project::Event::DiskBasedDiagnosticsFinished { .. }
348 | project::Event::DiagnosticsUpdated { .. } => {
349 if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off
350 {
351 this.update_diagnostics(cx);
352 cx.notify();
353 }
354 }
355 project::Event::WorktreeRemoved(id) => {
356 this.expanded_dir_ids.remove(id);
357 this.update_visible_entries(None, cx);
358 cx.notify();
359 }
360 project::Event::WorktreeUpdatedEntries(_, _)
361 | project::Event::WorktreeAdded(_)
362 | project::Event::WorktreeOrderChanged => {
363 this.update_visible_entries(None, cx);
364 cx.notify();
365 }
366 project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
367 if let Some((worktree, expanded_dir_ids)) = project
368 .read(cx)
369 .worktree_for_id(*worktree_id, cx)
370 .zip(this.expanded_dir_ids.get_mut(&worktree_id))
371 {
372 let worktree = worktree.read(cx);
373
374 let Some(entry) = worktree.entry_for_id(*entry_id) else {
375 return;
376 };
377 let include_ignored_dirs = !entry.is_ignored;
378
379 let mut dirs_to_expand = vec![*entry_id];
380 while let Some(current_id) = dirs_to_expand.pop() {
381 let Some(current_entry) = worktree.entry_for_id(current_id) else {
382 continue;
383 };
384 for child in worktree.child_entries(¤t_entry.path) {
385 if !child.is_dir() || (include_ignored_dirs && child.is_ignored) {
386 continue;
387 }
388
389 dirs_to_expand.push(child.id);
390
391 if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
392 expanded_dir_ids.insert(ix, child.id);
393 }
394 this.unfolded_dir_ids.insert(child.id);
395 }
396 }
397 this.update_visible_entries(None, cx);
398 cx.notify();
399 }
400 }
401 _ => {}
402 })
403 .detach();
404
405 let trash_action = [TypeId::of::<Trash>()];
406 let is_remote = project.read(cx).is_via_collab();
407
408 if is_remote {
409 CommandPaletteFilter::update_global(cx, |filter, _cx| {
410 filter.hide_action_types(&trash_action);
411 });
412 }
413
414 let filename_editor = cx.new(|cx| Editor::single_line(window, cx));
415
416 cx.subscribe(
417 &filename_editor,
418 |project_panel, _, editor_event, cx| match editor_event {
419 EditorEvent::BufferEdited => {
420 project_panel.populate_validation_error(cx);
421 project_panel.autoscroll(cx);
422 }
423 EditorEvent::SelectionsChanged { .. } => {
424 project_panel.autoscroll(cx);
425 }
426 EditorEvent::Blurred => {
427 if project_panel
428 .edit_state
429 .as_ref()
430 .map_or(false, |state| state.processing_filename.is_none())
431 {
432 project_panel.edit_state = None;
433 project_panel.update_visible_entries(None, cx);
434 cx.notify();
435 }
436 }
437 _ => {}
438 },
439 )
440 .detach();
441
442 cx.observe_global::<FileIcons>(|_, cx| {
443 cx.notify();
444 })
445 .detach();
446
447 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
448 cx.observe_global::<SettingsStore>(move |this, cx| {
449 let new_settings = *ProjectPanelSettings::get_global(cx);
450 if project_panel_settings != new_settings {
451 if project_panel_settings.hide_gitignore != new_settings.hide_gitignore {
452 this.update_visible_entries(None, cx);
453 }
454 project_panel_settings = new_settings;
455 this.update_diagnostics(cx);
456 cx.notify();
457 }
458 })
459 .detach();
460
461 let scroll_handle = UniformListScrollHandle::new();
462 let mut this = Self {
463 project: project.clone(),
464 hover_scroll_task: None,
465 fs: workspace.app_state().fs.clone(),
466 focus_handle,
467 visible_entries: Default::default(),
468 ancestors: Default::default(),
469 folded_directory_drag_target: None,
470 last_worktree_root_id: Default::default(),
471 last_external_paths_drag_over_entry: None,
472 last_selection_drag_over_entry: None,
473 expanded_dir_ids: Default::default(),
474 unfolded_dir_ids: Default::default(),
475 selection: None,
476 marked_entries: Default::default(),
477 edit_state: None,
478 context_menu: None,
479 filename_editor,
480 clipboard: None,
481 _dragged_entry_destination: None,
482 workspace: workspace.weak_handle(),
483 width: None,
484 pending_serialization: Task::ready(None),
485 show_scrollbar: !Self::should_autohide_scrollbar(cx),
486 hide_scrollbar_task: None,
487 vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
488 .parent_entity(&cx.entity()),
489 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
490 .parent_entity(&cx.entity()),
491 max_width_item_index: None,
492 diagnostics: Default::default(),
493 scroll_handle,
494 mouse_down: false,
495 hover_expand_task: None,
496 };
497 this.update_visible_entries(None, cx);
498
499 this
500 });
501
502 cx.subscribe_in(&project_panel, window, {
503 let project_panel = project_panel.downgrade();
504 move |workspace, _, event, window, cx| match event {
505 &Event::OpenedEntry {
506 entry_id,
507 focus_opened_item,
508 allow_preview,
509 } => {
510 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
511 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
512 let file_path = entry.path.clone();
513 let worktree_id = worktree.read(cx).id();
514 let entry_id = entry.id;
515 let is_via_ssh = project.read(cx).is_via_ssh();
516
517 workspace
518 .open_path_preview(
519 ProjectPath {
520 worktree_id,
521 path: file_path.clone(),
522 },
523 None,
524 focus_opened_item,
525 allow_preview,
526 true,
527 window, cx,
528 )
529 .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {
530 match e.error_code() {
531 ErrorCode::Disconnected => if is_via_ssh {
532 Some("Disconnected from SSH host".to_string())
533 } else {
534 Some("Disconnected from remote project".to_string())
535 },
536 ErrorCode::UnsharedItem => Some(format!(
537 "{} is not shared by the host. This could be because it has been marked as `private`",
538 file_path.display()
539 )),
540 // See note in worktree.rs where this error originates. Returning Some in this case prevents
541 // the error popup from saying "Try Again", which is a red herring in this case
542 ErrorCode::Internal if e.to_string().contains("File is too large to load") => Some(e.to_string()),
543 _ => None,
544 }
545 });
546
547 if let Some(project_panel) = project_panel.upgrade() {
548 // Always select and mark the entry, regardless of whether it is opened or not.
549 project_panel.update(cx, |project_panel, _| {
550 let entry = SelectedEntry { worktree_id, entry_id };
551 project_panel.marked_entries.clear();
552 project_panel.marked_entries.insert(entry);
553 project_panel.selection = Some(entry);
554 });
555 if !focus_opened_item {
556 let focus_handle = project_panel.read(cx).focus_handle.clone();
557 window.focus(&focus_handle);
558 }
559 }
560 }
561 }
562 }
563 &Event::SplitEntry { entry_id } => {
564 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
565 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
566 workspace
567 .split_path(
568 ProjectPath {
569 worktree_id: worktree.read(cx).id(),
570 path: entry.path.clone(),
571 },
572 window, cx,
573 )
574 .detach_and_log_err(cx);
575 }
576 }
577 }
578
579 _ => {}
580 }
581 })
582 .detach();
583
584 project_panel
585 }
586
587 pub async fn load(
588 workspace: WeakEntity<Workspace>,
589 mut cx: AsyncWindowContext,
590 ) -> Result<Entity<Self>> {
591 let serialized_panel = cx
592 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
593 .await
594 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
595 .log_err()
596 .flatten()
597 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
598 .transpose()
599 .log_err()
600 .flatten();
601
602 workspace.update_in(&mut cx, |workspace, window, cx| {
603 let panel = ProjectPanel::new(workspace, window, cx);
604 if let Some(serialized_panel) = serialized_panel {
605 panel.update(cx, |panel, cx| {
606 panel.width = serialized_panel.width.map(|px| px.round());
607 cx.notify();
608 });
609 }
610 panel
611 })
612 }
613
614 fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
615 let mut diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity> =
616 Default::default();
617 let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
618
619 if show_diagnostics_setting != ShowDiagnostics::Off {
620 self.project
621 .read(cx)
622 .diagnostic_summaries(false, cx)
623 .filter_map(|(path, _, diagnostic_summary)| {
624 if diagnostic_summary.error_count > 0 {
625 Some((path, DiagnosticSeverity::ERROR))
626 } else if show_diagnostics_setting == ShowDiagnostics::All
627 && diagnostic_summary.warning_count > 0
628 {
629 Some((path, DiagnosticSeverity::WARNING))
630 } else {
631 None
632 }
633 })
634 .for_each(|(project_path, diagnostic_severity)| {
635 let mut path_buffer = PathBuf::new();
636 Self::update_strongest_diagnostic_severity(
637 &mut diagnostics,
638 &project_path,
639 path_buffer.clone(),
640 diagnostic_severity,
641 );
642
643 for component in project_path.path.components() {
644 path_buffer.push(component);
645 Self::update_strongest_diagnostic_severity(
646 &mut diagnostics,
647 &project_path,
648 path_buffer.clone(),
649 diagnostic_severity,
650 );
651 }
652 });
653 }
654 self.diagnostics = diagnostics;
655 }
656
657 fn update_strongest_diagnostic_severity(
658 diagnostics: &mut HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
659 project_path: &ProjectPath,
660 path_buffer: PathBuf,
661 diagnostic_severity: DiagnosticSeverity,
662 ) {
663 diagnostics
664 .entry((project_path.worktree_id, path_buffer.clone()))
665 .and_modify(|strongest_diagnostic_severity| {
666 *strongest_diagnostic_severity =
667 cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
668 })
669 .or_insert(diagnostic_severity);
670 }
671
672 fn serialize(&mut self, cx: &mut Context<Self>) {
673 let width = self.width;
674 self.pending_serialization = cx.background_spawn(
675 async move {
676 KEY_VALUE_STORE
677 .write_kvp(
678 PROJECT_PANEL_KEY.into(),
679 serde_json::to_string(&SerializedProjectPanel { width })?,
680 )
681 .await?;
682 anyhow::Ok(())
683 }
684 .log_err(),
685 );
686 }
687
688 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
689 if !self.focus_handle.contains_focused(window, cx) {
690 cx.emit(Event::Focus);
691 }
692 }
693
694 fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
695 if !self.focus_handle.is_focused(window) {
696 self.confirm(&Confirm, window, cx);
697 }
698 }
699
700 fn deploy_context_menu(
701 &mut self,
702 position: Point<Pixels>,
703 entry_id: ProjectEntryId,
704 window: &mut Window,
705 cx: &mut Context<Self>,
706 ) {
707 let project = self.project.read(cx);
708
709 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
710 id
711 } else {
712 return;
713 };
714
715 self.selection = Some(SelectedEntry {
716 worktree_id,
717 entry_id,
718 });
719
720 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
721 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
722 let worktree = worktree.read(cx);
723 let is_root = Some(entry) == worktree.root_entry();
724 let is_dir = entry.is_dir();
725 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
726 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
727 let is_read_only = project.is_read_only(cx);
728 let is_remote = project.is_via_collab();
729 let is_local = project.is_local();
730
731 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
732 menu.context(self.focus_handle.clone()).map(|menu| {
733 if is_read_only {
734 menu.when(is_dir, |menu| {
735 menu.action("Search Inside", Box::new(NewSearchInDirectory))
736 })
737 } else {
738 menu.action("New File", Box::new(NewFile))
739 .action("New Folder", Box::new(NewDirectory))
740 .separator()
741 .when(is_local && cfg!(target_os = "macos"), |menu| {
742 menu.action("Reveal in Finder", Box::new(RevealInFileManager))
743 })
744 .when(is_local && cfg!(not(target_os = "macos")), |menu| {
745 menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
746 })
747 .when(is_local, |menu| {
748 menu.action("Open in Default App", Box::new(OpenWithSystem))
749 })
750 .action("Open in Terminal", Box::new(OpenInTerminal))
751 .when(is_dir, |menu| {
752 menu.separator()
753 .action("Find in Folder…", Box::new(NewSearchInDirectory))
754 })
755 .when(is_unfoldable, |menu| {
756 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
757 })
758 .when(is_foldable, |menu| {
759 menu.action("Fold Directory", Box::new(FoldDirectory))
760 })
761 .separator()
762 .action("Cut", Box::new(Cut))
763 .action("Copy", Box::new(Copy))
764 .action("Duplicate", Box::new(Duplicate))
765 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
766 .map(|menu| {
767 if self.clipboard.as_ref().is_some() {
768 menu.action("Paste", Box::new(Paste))
769 } else {
770 menu.disabled_action("Paste", Box::new(Paste))
771 }
772 })
773 .separator()
774 .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
775 .action(
776 "Copy Relative Path",
777 Box::new(zed_actions::workspace::CopyRelativePath),
778 )
779 .separator()
780 .when(!is_root || !cfg!(target_os = "windows"), |menu| {
781 menu.action("Rename", Box::new(Rename))
782 })
783 .when(!is_root & !is_remote, |menu| {
784 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
785 })
786 .when(!is_root, |menu| {
787 menu.action("Delete", Box::new(Delete { skip_prompt: false }))
788 })
789 .when(!is_remote & is_root, |menu| {
790 menu.separator()
791 .action(
792 "Add Folder to Project…",
793 Box::new(workspace::AddFolderToProject),
794 )
795 .action("Remove from Project", Box::new(RemoveFromProject))
796 })
797 .when(is_root, |menu| {
798 menu.separator()
799 .action("Collapse All", Box::new(CollapseAllEntries))
800 })
801 }
802 })
803 });
804
805 window.focus(&context_menu.focus_handle(cx));
806 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
807 this.context_menu.take();
808 cx.notify();
809 });
810 self.context_menu = Some((context_menu, position, subscription));
811 }
812
813 cx.notify();
814 }
815
816 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
817 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
818 return false;
819 }
820
821 if let Some(parent_path) = entry.path.parent() {
822 let snapshot = worktree.snapshot();
823 let mut child_entries = snapshot.child_entries(parent_path);
824 if let Some(child) = child_entries.next() {
825 if child_entries.next().is_none() {
826 return child.kind.is_dir();
827 }
828 }
829 };
830 false
831 }
832
833 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
834 if entry.is_dir() {
835 let snapshot = worktree.snapshot();
836
837 let mut child_entries = snapshot.child_entries(&entry.path);
838 if let Some(child) = child_entries.next() {
839 if child_entries.next().is_none() {
840 return child.kind.is_dir();
841 }
842 }
843 }
844 false
845 }
846
847 fn expand_selected_entry(
848 &mut self,
849 _: &ExpandSelectedEntry,
850 window: &mut Window,
851 cx: &mut Context<Self>,
852 ) {
853 if let Some((worktree, entry)) = self.selected_entry(cx) {
854 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
855 if folded_ancestors.current_ancestor_depth > 0 {
856 folded_ancestors.current_ancestor_depth -= 1;
857 cx.notify();
858 return;
859 }
860 }
861 if entry.is_dir() {
862 let worktree_id = worktree.id();
863 let entry_id = entry.id;
864 let expanded_dir_ids =
865 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
866 expanded_dir_ids
867 } else {
868 return;
869 };
870
871 match expanded_dir_ids.binary_search(&entry_id) {
872 Ok(_) => self.select_next(&SelectNext, window, cx),
873 Err(ix) => {
874 self.project.update(cx, |project, cx| {
875 project.expand_entry(worktree_id, entry_id, cx);
876 });
877
878 expanded_dir_ids.insert(ix, entry_id);
879 self.update_visible_entries(None, cx);
880 cx.notify();
881 }
882 }
883 }
884 }
885 }
886
887 fn collapse_selected_entry(
888 &mut self,
889 _: &CollapseSelectedEntry,
890 _: &mut Window,
891 cx: &mut Context<Self>,
892 ) {
893 let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
894 return;
895 };
896 self.collapse_entry(entry.clone(), worktree, cx)
897 }
898
899 fn collapse_entry(&mut self, entry: Entry, worktree: Entity<Worktree>, cx: &mut Context<Self>) {
900 let worktree = worktree.read(cx);
901 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
902 if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
903 folded_ancestors.current_ancestor_depth += 1;
904 cx.notify();
905 return;
906 }
907 }
908 let worktree_id = worktree.id();
909 let expanded_dir_ids =
910 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
911 expanded_dir_ids
912 } else {
913 return;
914 };
915
916 let mut entry = &entry;
917 loop {
918 let entry_id = entry.id;
919 match expanded_dir_ids.binary_search(&entry_id) {
920 Ok(ix) => {
921 expanded_dir_ids.remove(ix);
922 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
923 cx.notify();
924 break;
925 }
926 Err(_) => {
927 if let Some(parent_entry) =
928 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
929 {
930 entry = parent_entry;
931 } else {
932 break;
933 }
934 }
935 }
936 }
937 }
938
939 pub fn collapse_all_entries(
940 &mut self,
941 _: &CollapseAllEntries,
942 _: &mut Window,
943 cx: &mut Context<Self>,
944 ) {
945 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
946 // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
947 self.expanded_dir_ids
948 .retain(|_, expanded_entries| expanded_entries.is_empty());
949 self.update_visible_entries(None, cx);
950 cx.notify();
951 }
952
953 fn toggle_expanded(
954 &mut self,
955 entry_id: ProjectEntryId,
956 window: &mut Window,
957 cx: &mut Context<Self>,
958 ) {
959 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
960 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
961 self.project.update(cx, |project, cx| {
962 match expanded_dir_ids.binary_search(&entry_id) {
963 Ok(ix) => {
964 expanded_dir_ids.remove(ix);
965 }
966 Err(ix) => {
967 project.expand_entry(worktree_id, entry_id, cx);
968 expanded_dir_ids.insert(ix, entry_id);
969 }
970 }
971 });
972 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
973 window.focus(&self.focus_handle);
974 cx.notify();
975 }
976 }
977 }
978
979 fn toggle_expand_all(
980 &mut self,
981 entry_id: ProjectEntryId,
982 window: &mut Window,
983 cx: &mut Context<Self>,
984 ) {
985 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
986 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
987 match expanded_dir_ids.binary_search(&entry_id) {
988 Ok(_ix) => {
989 self.collapse_all_for_entry(worktree_id, entry_id, cx);
990 }
991 Err(_ix) => {
992 self.expand_all_for_entry(worktree_id, entry_id, cx);
993 }
994 }
995 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
996 window.focus(&self.focus_handle);
997 cx.notify();
998 }
999 }
1000 }
1001
1002 fn expand_all_for_entry(
1003 &mut self,
1004 worktree_id: WorktreeId,
1005 entry_id: ProjectEntryId,
1006 cx: &mut Context<Self>,
1007 ) {
1008 self.project.update(cx, |project, cx| {
1009 if let Some((worktree, expanded_dir_ids)) = project
1010 .worktree_for_id(worktree_id, cx)
1011 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1012 {
1013 if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
1014 task.detach();
1015 }
1016
1017 let worktree = worktree.read(cx);
1018
1019 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1020 loop {
1021 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1022 expanded_dir_ids.insert(ix, entry.id);
1023 }
1024
1025 if let Some(parent_entry) =
1026 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1027 {
1028 entry = parent_entry;
1029 } else {
1030 break;
1031 }
1032 }
1033 }
1034 }
1035 });
1036 }
1037
1038 fn collapse_all_for_entry(
1039 &mut self,
1040 worktree_id: WorktreeId,
1041 entry_id: ProjectEntryId,
1042 cx: &mut Context<Self>,
1043 ) {
1044 self.project.update(cx, |project, cx| {
1045 if let Some((worktree, expanded_dir_ids)) = project
1046 .worktree_for_id(worktree_id, cx)
1047 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1048 {
1049 let worktree = worktree.read(cx);
1050 let mut dirs_to_collapse = vec![entry_id];
1051 let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1052 while let Some(current_id) = dirs_to_collapse.pop() {
1053 let Some(current_entry) = worktree.entry_for_id(current_id) else {
1054 continue;
1055 };
1056 if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
1057 expanded_dir_ids.remove(ix);
1058 }
1059 if auto_fold_enabled {
1060 self.unfolded_dir_ids.remove(¤t_id);
1061 }
1062 for child in worktree.child_entries(¤t_entry.path) {
1063 if child.is_dir() {
1064 dirs_to_collapse.push(child.id);
1065 }
1066 }
1067 }
1068 }
1069 });
1070 }
1071
1072 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1073 if let Some(edit_state) = &self.edit_state {
1074 if edit_state.processing_filename.is_none() {
1075 self.filename_editor.update(cx, |editor, cx| {
1076 editor.move_to_beginning_of_line(
1077 &editor::actions::MoveToBeginningOfLine {
1078 stop_at_soft_wraps: false,
1079 stop_at_indent: false,
1080 },
1081 window,
1082 cx,
1083 );
1084 });
1085 return;
1086 }
1087 }
1088 if let Some(selection) = self.selection {
1089 let (mut worktree_ix, mut entry_ix, _) =
1090 self.index_for_selection(selection).unwrap_or_default();
1091 if entry_ix > 0 {
1092 entry_ix -= 1;
1093 } else if worktree_ix > 0 {
1094 worktree_ix -= 1;
1095 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
1096 } else {
1097 return;
1098 }
1099
1100 let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
1101 let selection = SelectedEntry {
1102 worktree_id: *worktree_id,
1103 entry_id: worktree_entries[entry_ix].id,
1104 };
1105 self.selection = Some(selection);
1106 if window.modifiers().shift {
1107 self.marked_entries.insert(selection);
1108 }
1109 self.autoscroll(cx);
1110 cx.notify();
1111 } else {
1112 self.select_first(&SelectFirst {}, window, cx);
1113 }
1114 }
1115
1116 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1117 if let Some(task) = self.confirm_edit(window, cx) {
1118 task.detach_and_notify_err(window, cx);
1119 }
1120 }
1121
1122 fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
1123 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
1124 self.open_internal(true, !preview_tabs_enabled, window, cx);
1125 }
1126
1127 fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context<Self>) {
1128 self.open_internal(false, true, window, cx);
1129 }
1130
1131 fn open_internal(
1132 &mut self,
1133 allow_preview: bool,
1134 focus_opened_item: bool,
1135 window: &mut Window,
1136 cx: &mut Context<Self>,
1137 ) {
1138 if let Some((_, entry)) = self.selected_entry(cx) {
1139 if entry.is_file() {
1140 self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
1141 cx.notify();
1142 } else {
1143 self.toggle_expanded(entry.id, window, cx);
1144 }
1145 }
1146 }
1147
1148 fn populate_validation_error(&mut self, cx: &mut Context<Self>) {
1149 let edit_state = match self.edit_state.as_mut() {
1150 Some(state) => state,
1151 None => return,
1152 };
1153 let filename = self.filename_editor.read(cx).text(cx);
1154 if !filename.is_empty() {
1155 if let Some(worktree) = self
1156 .project
1157 .read(cx)
1158 .worktree_for_id(edit_state.worktree_id, cx)
1159 {
1160 if let Some(entry) = worktree.read(cx).entry_for_id(edit_state.entry_id) {
1161 let mut already_exists = false;
1162 if edit_state.is_new_entry() {
1163 let new_path = entry.path.join(filename.trim_start_matches('/'));
1164 if worktree
1165 .read(cx)
1166 .entry_for_path(new_path.as_path())
1167 .is_some()
1168 {
1169 already_exists = true;
1170 }
1171 } else {
1172 let new_path = if let Some(parent) = entry.path.clone().parent() {
1173 parent.join(&filename)
1174 } else {
1175 filename.clone().into()
1176 };
1177 if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path())
1178 {
1179 if existing.id != entry.id {
1180 already_exists = true;
1181 }
1182 }
1183 };
1184 if already_exists {
1185 edit_state.validation_state = ValidationState::Error(format!(
1186 "File or directory '{}' already exists at location. Please choose a different name.",
1187 filename
1188 ));
1189 cx.notify();
1190 return;
1191 }
1192 }
1193 }
1194 let trimmed_filename = filename.trim();
1195 if trimmed_filename.is_empty() {
1196 edit_state.validation_state =
1197 ValidationState::Error("File or directory name cannot be empty.".to_string());
1198 cx.notify();
1199 return;
1200 }
1201 if trimmed_filename != filename {
1202 edit_state.validation_state = ValidationState::Warning(
1203 "File or directory name contains leading or trailing whitespace.".to_string(),
1204 );
1205 cx.notify();
1206 return;
1207 }
1208 }
1209 edit_state.validation_state = ValidationState::None;
1210 cx.notify();
1211 }
1212
1213 fn confirm_edit(
1214 &mut self,
1215 window: &mut Window,
1216 cx: &mut Context<Self>,
1217 ) -> Option<Task<Result<()>>> {
1218 let edit_state = self.edit_state.as_mut()?;
1219 let worktree_id = edit_state.worktree_id;
1220 let is_new_entry = edit_state.is_new_entry();
1221 let filename = self.filename_editor.read(cx).text(cx);
1222 if filename.trim().is_empty() {
1223 return None;
1224 }
1225 #[cfg(not(target_os = "windows"))]
1226 let filename_indicates_dir = filename.ends_with("/");
1227 // On Windows, path separator could be either `/` or `\`.
1228 #[cfg(target_os = "windows")]
1229 let filename_indicates_dir = filename.ends_with("/") || filename.ends_with("\\");
1230 edit_state.is_dir =
1231 edit_state.is_dir || (edit_state.is_new_entry() && filename_indicates_dir);
1232 let is_dir = edit_state.is_dir;
1233 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
1234 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
1235
1236 let edit_task;
1237 let edited_entry_id;
1238 if is_new_entry {
1239 self.selection = Some(SelectedEntry {
1240 worktree_id,
1241 entry_id: NEW_ENTRY_ID,
1242 });
1243 let new_path = entry.path.join(filename.trim_start_matches('/'));
1244 if worktree
1245 .read(cx)
1246 .entry_for_path(new_path.as_path())
1247 .is_some()
1248 {
1249 return None;
1250 }
1251
1252 edited_entry_id = NEW_ENTRY_ID;
1253 edit_task = self.project.update(cx, |project, cx| {
1254 project.create_entry((worktree_id, &new_path), is_dir, cx)
1255 });
1256 } else {
1257 let new_path = if let Some(parent) = entry.path.clone().parent() {
1258 parent.join(&filename)
1259 } else {
1260 filename.clone().into()
1261 };
1262 if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path()) {
1263 if existing.id == entry.id {
1264 window.focus(&self.focus_handle);
1265 }
1266 return None;
1267 }
1268 edited_entry_id = entry.id;
1269 edit_task = self.project.update(cx, |project, cx| {
1270 project.rename_entry(entry.id, new_path.as_path(), cx)
1271 });
1272 };
1273
1274 window.focus(&self.focus_handle);
1275 edit_state.processing_filename = Some(filename);
1276 cx.notify();
1277
1278 Some(cx.spawn_in(window, async move |project_panel, cx| {
1279 let new_entry = edit_task.await;
1280 project_panel.update(cx, |project_panel, cx| {
1281 project_panel.edit_state = None;
1282 cx.notify();
1283 })?;
1284
1285 match new_entry {
1286 Err(e) => {
1287 project_panel.update( cx, |project_panel, cx| {
1288 project_panel.marked_entries.clear();
1289 project_panel.update_visible_entries(None, cx);
1290 }).ok();
1291 Err(e)?;
1292 }
1293 Ok(CreatedEntry::Included(new_entry)) => {
1294 project_panel.update( cx, |project_panel, cx| {
1295 if let Some(selection) = &mut project_panel.selection {
1296 if selection.entry_id == edited_entry_id {
1297 selection.worktree_id = worktree_id;
1298 selection.entry_id = new_entry.id;
1299 project_panel.marked_entries.clear();
1300 project_panel.expand_to_selection(cx);
1301 }
1302 }
1303 project_panel.update_visible_entries(None, cx);
1304 if is_new_entry && !is_dir {
1305 project_panel.open_entry(new_entry.id, true, false, cx);
1306 }
1307 cx.notify();
1308 })?;
1309 }
1310 Ok(CreatedEntry::Excluded { abs_path }) => {
1311 if let Some(open_task) = project_panel
1312 .update_in( cx, |project_panel, window, cx| {
1313 project_panel.marked_entries.clear();
1314 project_panel.update_visible_entries(None, cx);
1315
1316 if is_dir {
1317 project_panel.project.update(cx, |_, cx| {
1318 cx.emit(project::Event::Toast {
1319 notification_id: "excluded-directory".into(),
1320 message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
1321 })
1322 });
1323 None
1324 } else {
1325 project_panel
1326 .workspace
1327 .update(cx, |workspace, cx| {
1328 workspace.open_abs_path(abs_path, OpenOptions { visible: Some(OpenVisible::All), ..Default::default() }, window, cx)
1329 })
1330 .ok()
1331 }
1332 })
1333 .ok()
1334 .flatten()
1335 {
1336 let _ = open_task.await?;
1337 }
1338 }
1339 }
1340 Ok(())
1341 }))
1342 }
1343
1344 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1345 let previous_edit_state = self.edit_state.take();
1346 self.update_visible_entries(None, cx);
1347 self.marked_entries.clear();
1348
1349 if let Some(previously_focused) =
1350 previous_edit_state.and_then(|edit_state| edit_state.previously_focused)
1351 {
1352 self.selection = Some(previously_focused);
1353 self.autoscroll(cx);
1354 }
1355
1356 window.focus(&self.focus_handle);
1357 cx.notify();
1358 }
1359
1360 fn open_entry(
1361 &mut self,
1362 entry_id: ProjectEntryId,
1363 focus_opened_item: bool,
1364 allow_preview: bool,
1365
1366 cx: &mut Context<Self>,
1367 ) {
1368 cx.emit(Event::OpenedEntry {
1369 entry_id,
1370 focus_opened_item,
1371 allow_preview,
1372 });
1373 }
1374
1375 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut Context<Self>) {
1376 cx.emit(Event::SplitEntry { entry_id });
1377 }
1378
1379 fn new_file(&mut self, _: &NewFile, window: &mut Window, cx: &mut Context<Self>) {
1380 self.add_entry(false, window, cx)
1381 }
1382
1383 fn new_directory(&mut self, _: &NewDirectory, window: &mut Window, cx: &mut Context<Self>) {
1384 self.add_entry(true, window, cx)
1385 }
1386
1387 fn add_entry(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context<Self>) {
1388 if let Some(SelectedEntry {
1389 worktree_id,
1390 entry_id,
1391 }) = self.selection
1392 {
1393 let directory_id;
1394 let new_entry_id = self.resolve_entry(entry_id);
1395 if let Some((worktree, expanded_dir_ids)) = self
1396 .project
1397 .read(cx)
1398 .worktree_for_id(worktree_id, cx)
1399 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1400 {
1401 let worktree = worktree.read(cx);
1402 if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
1403 loop {
1404 if entry.is_dir() {
1405 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1406 expanded_dir_ids.insert(ix, entry.id);
1407 }
1408 directory_id = entry.id;
1409 break;
1410 } else {
1411 if let Some(parent_path) = entry.path.parent() {
1412 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1413 entry = parent_entry;
1414 continue;
1415 }
1416 }
1417 return;
1418 }
1419 }
1420 } else {
1421 return;
1422 };
1423 } else {
1424 return;
1425 };
1426 self.marked_entries.clear();
1427 self.edit_state = Some(EditState {
1428 worktree_id,
1429 entry_id: directory_id,
1430 leaf_entry_id: None,
1431 is_dir,
1432 processing_filename: None,
1433 previously_focused: self.selection,
1434 depth: 0,
1435 validation_state: ValidationState::None,
1436 });
1437 self.filename_editor.update(cx, |editor, cx| {
1438 editor.clear(window, cx);
1439 window.focus(&editor.focus_handle(cx));
1440 });
1441 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1442 self.autoscroll(cx);
1443 cx.notify();
1444 }
1445 }
1446
1447 fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1448 if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1449 ancestors
1450 .ancestors
1451 .get(ancestors.current_ancestor_depth)
1452 .copied()
1453 .unwrap_or(leaf_entry_id)
1454 } else {
1455 leaf_entry_id
1456 }
1457 }
1458
1459 fn rename_impl(
1460 &mut self,
1461 selection: Option<Range<usize>>,
1462 window: &mut Window,
1463 cx: &mut Context<Self>,
1464 ) {
1465 if let Some(SelectedEntry {
1466 worktree_id,
1467 entry_id,
1468 }) = self.selection
1469 {
1470 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1471 let sub_entry_id = self.unflatten_entry_id(entry_id);
1472 if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1473 #[cfg(target_os = "windows")]
1474 if Some(entry) == worktree.read(cx).root_entry() {
1475 return;
1476 }
1477 self.edit_state = Some(EditState {
1478 worktree_id,
1479 entry_id: sub_entry_id,
1480 leaf_entry_id: Some(entry_id),
1481 is_dir: entry.is_dir(),
1482 processing_filename: None,
1483 previously_focused: None,
1484 depth: 0,
1485 validation_state: ValidationState::None,
1486 });
1487 let file_name = entry
1488 .path
1489 .file_name()
1490 .map(|s| s.to_string_lossy())
1491 .unwrap_or_default()
1492 .to_string();
1493 let selection = selection.unwrap_or_else(|| {
1494 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1495 let selection_end =
1496 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1497 0..selection_end
1498 });
1499 self.filename_editor.update(cx, |editor, cx| {
1500 editor.set_text(file_name, window, cx);
1501 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1502 s.select_ranges([selection])
1503 });
1504 window.focus(&editor.focus_handle(cx));
1505 });
1506 self.update_visible_entries(None, cx);
1507 self.autoscroll(cx);
1508 cx.notify();
1509 }
1510 }
1511 }
1512 }
1513
1514 fn rename(&mut self, _: &Rename, window: &mut Window, cx: &mut Context<Self>) {
1515 self.rename_impl(None, window, cx);
1516 }
1517
1518 fn trash(&mut self, action: &Trash, window: &mut Window, cx: &mut Context<Self>) {
1519 self.remove(true, action.skip_prompt, window, cx);
1520 }
1521
1522 fn delete(&mut self, action: &Delete, window: &mut Window, cx: &mut Context<Self>) {
1523 self.remove(false, action.skip_prompt, window, cx);
1524 }
1525
1526 fn remove(
1527 &mut self,
1528 trash: bool,
1529 skip_prompt: bool,
1530 window: &mut Window,
1531 cx: &mut Context<ProjectPanel>,
1532 ) {
1533 maybe!({
1534 let items_to_delete = self.disjoint_entries(cx);
1535 if items_to_delete.is_empty() {
1536 return None;
1537 }
1538 let project = self.project.read(cx);
1539
1540 let mut dirty_buffers = 0;
1541 let file_paths = items_to_delete
1542 .iter()
1543 .filter_map(|selection| {
1544 let project_path = project.path_for_entry(selection.entry_id, cx)?;
1545 dirty_buffers +=
1546 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
1547 Some((
1548 selection.entry_id,
1549 project_path
1550 .path
1551 .file_name()?
1552 .to_string_lossy()
1553 .into_owned(),
1554 ))
1555 })
1556 .collect::<Vec<_>>();
1557 if file_paths.is_empty() {
1558 return None;
1559 }
1560 let answer = if !skip_prompt {
1561 let operation = if trash { "Trash" } else { "Delete" };
1562 let prompt = match file_paths.first() {
1563 Some((_, path)) if file_paths.len() == 1 => {
1564 let unsaved_warning = if dirty_buffers > 0 {
1565 "\n\nIt has unsaved changes, which will be lost."
1566 } else {
1567 ""
1568 };
1569
1570 format!("{operation} {path}?{unsaved_warning}")
1571 }
1572 _ => {
1573 const CUTOFF_POINT: usize = 10;
1574 let names = if file_paths.len() > CUTOFF_POINT {
1575 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1576 let mut paths = file_paths
1577 .iter()
1578 .map(|(_, path)| path.clone())
1579 .take(CUTOFF_POINT)
1580 .collect::<Vec<_>>();
1581 paths.truncate(CUTOFF_POINT);
1582 if truncated_path_counts == 1 {
1583 paths.push(".. 1 file not shown".into());
1584 } else {
1585 paths.push(format!(".. {} files not shown", truncated_path_counts));
1586 }
1587 paths
1588 } else {
1589 file_paths.iter().map(|(_, path)| path.clone()).collect()
1590 };
1591 let unsaved_warning = if dirty_buffers == 0 {
1592 String::new()
1593 } else if dirty_buffers == 1 {
1594 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
1595 } else {
1596 format!(
1597 "\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
1598 )
1599 };
1600
1601 format!(
1602 "Do you want to {} the following {} files?\n{}{unsaved_warning}",
1603 operation.to_lowercase(),
1604 file_paths.len(),
1605 names.join("\n")
1606 )
1607 }
1608 };
1609 Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx))
1610 } else {
1611 None
1612 };
1613 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
1614 cx.spawn_in(window, async move |panel, cx| {
1615 if let Some(answer) = answer {
1616 if answer.await != Ok(0) {
1617 return anyhow::Ok(());
1618 }
1619 }
1620 for (entry_id, _) in file_paths {
1621 panel
1622 .update(cx, |panel, cx| {
1623 panel
1624 .project
1625 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1626 .context("no such entry")
1627 })??
1628 .await?;
1629 }
1630 panel.update_in(cx, |panel, window, cx| {
1631 if let Some(next_selection) = next_selection {
1632 panel.selection = Some(next_selection);
1633 panel.autoscroll(cx);
1634 } else {
1635 panel.select_last(&SelectLast {}, window, cx);
1636 }
1637 })?;
1638 Ok(())
1639 })
1640 .detach_and_log_err(cx);
1641 Some(())
1642 });
1643 }
1644
1645 fn find_next_selection_after_deletion(
1646 &self,
1647 sanitized_entries: BTreeSet<SelectedEntry>,
1648 cx: &mut Context<Self>,
1649 ) -> Option<SelectedEntry> {
1650 if sanitized_entries.is_empty() {
1651 return None;
1652 }
1653 let project = self.project.read(cx);
1654 let (worktree_id, worktree) = sanitized_entries
1655 .iter()
1656 .map(|entry| entry.worktree_id)
1657 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
1658 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
1659 let git_store = project.git_store().read(cx);
1660
1661 let marked_entries_in_worktree = sanitized_entries
1662 .iter()
1663 .filter(|e| e.worktree_id == worktree_id)
1664 .collect::<HashSet<_>>();
1665 let latest_entry = marked_entries_in_worktree
1666 .iter()
1667 .max_by(|a, b| {
1668 match (
1669 worktree.entry_for_id(a.entry_id),
1670 worktree.entry_for_id(b.entry_id),
1671 ) {
1672 (Some(a), Some(b)) => {
1673 compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
1674 }
1675 _ => cmp::Ordering::Equal,
1676 }
1677 })
1678 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
1679
1680 let parent_path = latest_entry.path.parent()?;
1681 let parent_entry = worktree.entry_for_path(parent_path)?;
1682
1683 // Remove all siblings that are being deleted except the last marked entry
1684 let repo_snapshots = git_store.repo_snapshots(cx);
1685 let worktree_snapshot = worktree.snapshot();
1686 let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore;
1687 let mut siblings: Vec<_> =
1688 ChildEntriesGitIter::new(&repo_snapshots, &worktree_snapshot, parent_path)
1689 .filter(|sibling| {
1690 (sibling.id == latest_entry.id)
1691 || (!marked_entries_in_worktree.contains(&&SelectedEntry {
1692 worktree_id,
1693 entry_id: sibling.id,
1694 }) && (!hide_gitignore || !sibling.is_ignored))
1695 })
1696 .map(|entry| entry.to_owned())
1697 .collect();
1698
1699 project::sort_worktree_entries(&mut siblings);
1700 let sibling_entry_index = siblings
1701 .iter()
1702 .position(|sibling| sibling.id == latest_entry.id)?;
1703
1704 if let Some(next_sibling) = sibling_entry_index
1705 .checked_add(1)
1706 .and_then(|i| siblings.get(i))
1707 {
1708 return Some(SelectedEntry {
1709 worktree_id,
1710 entry_id: next_sibling.id,
1711 });
1712 }
1713 if let Some(prev_sibling) = sibling_entry_index
1714 .checked_sub(1)
1715 .and_then(|i| siblings.get(i))
1716 {
1717 return Some(SelectedEntry {
1718 worktree_id,
1719 entry_id: prev_sibling.id,
1720 });
1721 }
1722 // No neighbour sibling found, fall back to parent
1723 Some(SelectedEntry {
1724 worktree_id,
1725 entry_id: parent_entry.id,
1726 })
1727 }
1728
1729 fn unfold_directory(&mut self, _: &UnfoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1730 if let Some((worktree, entry)) = self.selected_entry(cx) {
1731 self.unfolded_dir_ids.insert(entry.id);
1732
1733 let snapshot = worktree.snapshot();
1734 let mut parent_path = entry.path.parent();
1735 while let Some(path) = parent_path {
1736 if let Some(parent_entry) = worktree.entry_for_path(path) {
1737 let mut children_iter = snapshot.child_entries(path);
1738
1739 if children_iter.by_ref().take(2).count() > 1 {
1740 break;
1741 }
1742
1743 self.unfolded_dir_ids.insert(parent_entry.id);
1744 parent_path = path.parent();
1745 } else {
1746 break;
1747 }
1748 }
1749
1750 self.update_visible_entries(None, cx);
1751 self.autoscroll(cx);
1752 cx.notify();
1753 }
1754 }
1755
1756 fn fold_directory(&mut self, _: &FoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1757 if let Some((worktree, entry)) = self.selected_entry(cx) {
1758 self.unfolded_dir_ids.remove(&entry.id);
1759
1760 let snapshot = worktree.snapshot();
1761 let mut path = &*entry.path;
1762 loop {
1763 let mut child_entries_iter = snapshot.child_entries(path);
1764 if let Some(child) = child_entries_iter.next() {
1765 if child_entries_iter.next().is_none() && child.is_dir() {
1766 self.unfolded_dir_ids.remove(&child.id);
1767 path = &*child.path;
1768 } else {
1769 break;
1770 }
1771 } else {
1772 break;
1773 }
1774 }
1775
1776 self.update_visible_entries(None, cx);
1777 self.autoscroll(cx);
1778 cx.notify();
1779 }
1780 }
1781
1782 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1783 if let Some(edit_state) = &self.edit_state {
1784 if edit_state.processing_filename.is_none() {
1785 self.filename_editor.update(cx, |editor, cx| {
1786 editor.move_to_end_of_line(
1787 &editor::actions::MoveToEndOfLine {
1788 stop_at_soft_wraps: false,
1789 },
1790 window,
1791 cx,
1792 );
1793 });
1794 return;
1795 }
1796 }
1797 if let Some(selection) = self.selection {
1798 let (mut worktree_ix, mut entry_ix, _) =
1799 self.index_for_selection(selection).unwrap_or_default();
1800 if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1801 if entry_ix + 1 < worktree_entries.len() {
1802 entry_ix += 1;
1803 } else {
1804 worktree_ix += 1;
1805 entry_ix = 0;
1806 }
1807 }
1808
1809 if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1810 {
1811 if let Some(entry) = worktree_entries.get(entry_ix) {
1812 let selection = SelectedEntry {
1813 worktree_id: *worktree_id,
1814 entry_id: entry.id,
1815 };
1816 self.selection = Some(selection);
1817 if window.modifiers().shift {
1818 self.marked_entries.insert(selection);
1819 }
1820
1821 self.autoscroll(cx);
1822 cx.notify();
1823 }
1824 }
1825 } else {
1826 self.select_first(&SelectFirst {}, window, cx);
1827 }
1828 }
1829
1830 fn select_prev_diagnostic(
1831 &mut self,
1832 _: &SelectPrevDiagnostic,
1833 _: &mut Window,
1834 cx: &mut Context<Self>,
1835 ) {
1836 let selection = self.find_entry(
1837 self.selection.as_ref(),
1838 true,
1839 |entry, worktree_id| {
1840 (self.selection.is_none()
1841 || self.selection.is_some_and(|selection| {
1842 if selection.worktree_id == worktree_id {
1843 selection.entry_id != entry.id
1844 } else {
1845 true
1846 }
1847 }))
1848 && entry.is_file()
1849 && self
1850 .diagnostics
1851 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1852 },
1853 cx,
1854 );
1855
1856 if let Some(selection) = selection {
1857 self.selection = Some(selection);
1858 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1859 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1860 self.autoscroll(cx);
1861 cx.notify();
1862 }
1863 }
1864
1865 fn select_next_diagnostic(
1866 &mut self,
1867 _: &SelectNextDiagnostic,
1868 _: &mut Window,
1869 cx: &mut Context<Self>,
1870 ) {
1871 let selection = self.find_entry(
1872 self.selection.as_ref(),
1873 false,
1874 |entry, worktree_id| {
1875 (self.selection.is_none()
1876 || self.selection.is_some_and(|selection| {
1877 if selection.worktree_id == worktree_id {
1878 selection.entry_id != entry.id
1879 } else {
1880 true
1881 }
1882 }))
1883 && entry.is_file()
1884 && self
1885 .diagnostics
1886 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1887 },
1888 cx,
1889 );
1890
1891 if let Some(selection) = selection {
1892 self.selection = Some(selection);
1893 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1894 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1895 self.autoscroll(cx);
1896 cx.notify();
1897 }
1898 }
1899
1900 fn select_prev_git_entry(
1901 &mut self,
1902 _: &SelectPrevGitEntry,
1903 _: &mut Window,
1904 cx: &mut Context<Self>,
1905 ) {
1906 let selection = self.find_entry(
1907 self.selection.as_ref(),
1908 true,
1909 |entry, worktree_id| {
1910 (self.selection.is_none()
1911 || self.selection.is_some_and(|selection| {
1912 if selection.worktree_id == worktree_id {
1913 selection.entry_id != entry.id
1914 } else {
1915 true
1916 }
1917 }))
1918 && entry.is_file()
1919 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1920 },
1921 cx,
1922 );
1923
1924 if let Some(selection) = selection {
1925 self.selection = Some(selection);
1926 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1927 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1928 self.autoscroll(cx);
1929 cx.notify();
1930 }
1931 }
1932
1933 fn select_prev_directory(
1934 &mut self,
1935 _: &SelectPrevDirectory,
1936 _: &mut Window,
1937 cx: &mut Context<Self>,
1938 ) {
1939 let selection = self.find_visible_entry(
1940 self.selection.as_ref(),
1941 true,
1942 |entry, worktree_id| {
1943 (self.selection.is_none()
1944 || self.selection.is_some_and(|selection| {
1945 if selection.worktree_id == worktree_id {
1946 selection.entry_id != entry.id
1947 } else {
1948 true
1949 }
1950 }))
1951 && entry.is_dir()
1952 },
1953 cx,
1954 );
1955
1956 if let Some(selection) = selection {
1957 self.selection = Some(selection);
1958 self.autoscroll(cx);
1959 cx.notify();
1960 }
1961 }
1962
1963 fn select_next_directory(
1964 &mut self,
1965 _: &SelectNextDirectory,
1966 _: &mut Window,
1967 cx: &mut Context<Self>,
1968 ) {
1969 let selection = self.find_visible_entry(
1970 self.selection.as_ref(),
1971 false,
1972 |entry, worktree_id| {
1973 (self.selection.is_none()
1974 || self.selection.is_some_and(|selection| {
1975 if selection.worktree_id == worktree_id {
1976 selection.entry_id != entry.id
1977 } else {
1978 true
1979 }
1980 }))
1981 && entry.is_dir()
1982 },
1983 cx,
1984 );
1985
1986 if let Some(selection) = selection {
1987 self.selection = Some(selection);
1988 self.autoscroll(cx);
1989 cx.notify();
1990 }
1991 }
1992
1993 fn select_next_git_entry(
1994 &mut self,
1995 _: &SelectNextGitEntry,
1996 _: &mut Window,
1997 cx: &mut Context<Self>,
1998 ) {
1999 let selection = self.find_entry(
2000 self.selection.as_ref(),
2001 false,
2002 |entry, worktree_id| {
2003 (self.selection.is_none()
2004 || self.selection.is_some_and(|selection| {
2005 if selection.worktree_id == worktree_id {
2006 selection.entry_id != entry.id
2007 } else {
2008 true
2009 }
2010 }))
2011 && entry.is_file()
2012 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2013 },
2014 cx,
2015 );
2016
2017 if let Some(selection) = selection {
2018 self.selection = Some(selection);
2019 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2020 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
2021 self.autoscroll(cx);
2022 cx.notify();
2023 }
2024 }
2025
2026 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
2027 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2028 if let Some(parent) = entry.path.parent() {
2029 let worktree = worktree.read(cx);
2030 if let Some(parent_entry) = worktree.entry_for_path(parent) {
2031 self.selection = Some(SelectedEntry {
2032 worktree_id: worktree.id(),
2033 entry_id: parent_entry.id,
2034 });
2035 self.autoscroll(cx);
2036 cx.notify();
2037 }
2038 }
2039 } else {
2040 self.select_first(&SelectFirst {}, window, cx);
2041 }
2042 }
2043
2044 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
2045 let worktree = self
2046 .visible_entries
2047 .first()
2048 .and_then(|(worktree_id, _, _)| {
2049 self.project.read(cx).worktree_for_id(*worktree_id, cx)
2050 });
2051 if let Some(worktree) = worktree {
2052 let worktree = worktree.read(cx);
2053 let worktree_id = worktree.id();
2054 if let Some(root_entry) = worktree.root_entry() {
2055 let selection = SelectedEntry {
2056 worktree_id,
2057 entry_id: root_entry.id,
2058 };
2059 self.selection = Some(selection);
2060 if window.modifiers().shift {
2061 self.marked_entries.insert(selection);
2062 }
2063 self.autoscroll(cx);
2064 cx.notify();
2065 }
2066 }
2067 }
2068
2069 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
2070 if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.last() {
2071 let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
2072 if let (Some(worktree), Some(entry)) = (worktree, visible_worktree_entries.last()) {
2073 let worktree = worktree.read(cx);
2074 if let Some(entry) = worktree.entry_for_id(entry.id) {
2075 let selection = SelectedEntry {
2076 worktree_id: *worktree_id,
2077 entry_id: entry.id,
2078 };
2079 self.selection = Some(selection);
2080 self.autoscroll(cx);
2081 cx.notify();
2082 }
2083 }
2084 }
2085 }
2086
2087 fn autoscroll(&mut self, cx: &mut Context<Self>) {
2088 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2089 self.scroll_handle
2090 .scroll_to_item(index, ScrollStrategy::Center);
2091 cx.notify();
2092 }
2093 }
2094
2095 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
2096 let entries = self.disjoint_entries(cx);
2097 if !entries.is_empty() {
2098 self.clipboard = Some(ClipboardEntry::Cut(entries));
2099 cx.notify();
2100 }
2101 }
2102
2103 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
2104 let entries = self.disjoint_entries(cx);
2105 if !entries.is_empty() {
2106 self.clipboard = Some(ClipboardEntry::Copied(entries));
2107 cx.notify();
2108 }
2109 }
2110
2111 fn create_paste_path(
2112 &self,
2113 source: &SelectedEntry,
2114 (worktree, target_entry): (Entity<Worktree>, &Entry),
2115 cx: &App,
2116 ) -> Option<(PathBuf, Option<Range<usize>>)> {
2117 let mut new_path = target_entry.path.to_path_buf();
2118 // If we're pasting into a file, or a directory into itself, go up one level.
2119 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
2120 new_path.pop();
2121 }
2122 let clipboard_entry_file_name = self
2123 .project
2124 .read(cx)
2125 .path_for_entry(source.entry_id, cx)?
2126 .path
2127 .file_name()?
2128 .to_os_string();
2129 new_path.push(&clipboard_entry_file_name);
2130 let extension = new_path.extension().map(|e| e.to_os_string());
2131 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
2132 let file_name_len = file_name_without_extension.to_string_lossy().len();
2133 let mut disambiguation_range = None;
2134 let mut ix = 0;
2135 {
2136 let worktree = worktree.read(cx);
2137 while worktree.entry_for_path(&new_path).is_some() {
2138 new_path.pop();
2139
2140 let mut new_file_name = file_name_without_extension.to_os_string();
2141
2142 let disambiguation = " copy";
2143 let mut disambiguation_len = disambiguation.len();
2144
2145 new_file_name.push(disambiguation);
2146
2147 if ix > 0 {
2148 let extra_disambiguation = format!(" {}", ix);
2149 disambiguation_len += extra_disambiguation.len();
2150
2151 new_file_name.push(extra_disambiguation);
2152 }
2153 if let Some(extension) = extension.as_ref() {
2154 new_file_name.push(".");
2155 new_file_name.push(extension);
2156 }
2157
2158 new_path.push(new_file_name);
2159 disambiguation_range = Some(file_name_len..(file_name_len + disambiguation_len));
2160 ix += 1;
2161 }
2162 }
2163 Some((new_path, disambiguation_range))
2164 }
2165
2166 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2167 maybe!({
2168 let (worktree, entry) = self.selected_entry_handle(cx)?;
2169 let entry = entry.clone();
2170 let worktree_id = worktree.read(cx).id();
2171 let clipboard_entries = self
2172 .clipboard
2173 .as_ref()
2174 .filter(|clipboard| !clipboard.items().is_empty())?;
2175 enum PasteTask {
2176 Rename(Task<Result<CreatedEntry>>),
2177 Copy(Task<Result<Option<Entry>>>),
2178 }
2179 let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
2180 IndexMap::default();
2181 let mut disambiguation_range = None;
2182 let clip_is_cut = clipboard_entries.is_cut();
2183 for clipboard_entry in clipboard_entries.items() {
2184 let (new_path, new_disambiguation_range) =
2185 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
2186 let clip_entry_id = clipboard_entry.entry_id;
2187 let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
2188 let relative_worktree_source_path = if !is_same_worktree {
2189 let target_base_path = worktree.read(cx).abs_path();
2190 let clipboard_project_path =
2191 self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
2192 let clipboard_abs_path = self
2193 .project
2194 .read(cx)
2195 .absolute_path(&clipboard_project_path, cx)?;
2196 Some(relativize_path(
2197 &target_base_path,
2198 clipboard_abs_path.as_path(),
2199 ))
2200 } else {
2201 None
2202 };
2203 let task = if clip_is_cut && is_same_worktree {
2204 let task = self.project.update(cx, |project, cx| {
2205 project.rename_entry(clip_entry_id, new_path, cx)
2206 });
2207 PasteTask::Rename(task)
2208 } else {
2209 let entry_id = if is_same_worktree {
2210 clip_entry_id
2211 } else {
2212 entry.id
2213 };
2214 let task = self.project.update(cx, |project, cx| {
2215 project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
2216 });
2217 PasteTask::Copy(task)
2218 };
2219 let needs_delete = !is_same_worktree && clip_is_cut;
2220 paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
2221 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2222 }
2223
2224 let item_count = paste_entry_tasks.len();
2225
2226 cx.spawn_in(window, async move |project_panel, cx| {
2227 let mut last_succeed = None;
2228 let mut need_delete_ids = Vec::new();
2229 for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
2230 match task {
2231 PasteTask::Rename(task) => {
2232 if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
2233 last_succeed = Some(entry);
2234 }
2235 }
2236 PasteTask::Copy(task) => {
2237 if let Some(Some(entry)) = task.await.log_err() {
2238 last_succeed = Some(entry);
2239 if need_delete {
2240 need_delete_ids.push(entry_id);
2241 }
2242 }
2243 }
2244 }
2245 }
2246 // remove entry for cut in difference worktree
2247 for entry_id in need_delete_ids {
2248 project_panel
2249 .update(cx, |project_panel, cx| {
2250 project_panel
2251 .project
2252 .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
2253 .ok_or_else(|| anyhow!("no such entry"))
2254 })??
2255 .await?;
2256 }
2257 // update selection
2258 if let Some(entry) = last_succeed {
2259 project_panel
2260 .update_in(cx, |project_panel, window, cx| {
2261 project_panel.selection = Some(SelectedEntry {
2262 worktree_id,
2263 entry_id: entry.id,
2264 });
2265
2266 if item_count == 1 {
2267 // open entry if not dir, and only focus if rename is not pending
2268 if !entry.is_dir() {
2269 project_panel.open_entry(
2270 entry.id,
2271 disambiguation_range.is_none(),
2272 false,
2273 cx,
2274 );
2275 }
2276
2277 // if only one entry was pasted and it was disambiguated, open the rename editor
2278 if disambiguation_range.is_some() {
2279 cx.defer_in(window, |this, window, cx| {
2280 this.rename_impl(disambiguation_range, window, cx);
2281 });
2282 }
2283 }
2284 })
2285 .ok();
2286 }
2287
2288 anyhow::Ok(())
2289 })
2290 .detach_and_log_err(cx);
2291
2292 self.expand_entry(worktree_id, entry.id, cx);
2293 Some(())
2294 });
2295 }
2296
2297 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
2298 self.copy(&Copy {}, window, cx);
2299 self.paste(&Paste {}, window, cx);
2300 }
2301
2302 fn copy_path(
2303 &mut self,
2304 _: &zed_actions::workspace::CopyPath,
2305 _: &mut Window,
2306 cx: &mut Context<Self>,
2307 ) {
2308 let abs_file_paths = {
2309 let project = self.project.read(cx);
2310 self.effective_entries()
2311 .into_iter()
2312 .filter_map(|entry| {
2313 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
2314 Some(
2315 project
2316 .worktree_for_id(entry.worktree_id, cx)?
2317 .read(cx)
2318 .abs_path()
2319 .join(entry_path)
2320 .to_string_lossy()
2321 .to_string(),
2322 )
2323 })
2324 .collect::<Vec<_>>()
2325 };
2326 if !abs_file_paths.is_empty() {
2327 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
2328 }
2329 }
2330
2331 fn copy_relative_path(
2332 &mut self,
2333 _: &zed_actions::workspace::CopyRelativePath,
2334 _: &mut Window,
2335 cx: &mut Context<Self>,
2336 ) {
2337 let file_paths = {
2338 let project = self.project.read(cx);
2339 self.effective_entries()
2340 .into_iter()
2341 .filter_map(|entry| {
2342 Some(
2343 project
2344 .path_for_entry(entry.entry_id, cx)?
2345 .path
2346 .to_string_lossy()
2347 .to_string(),
2348 )
2349 })
2350 .collect::<Vec<_>>()
2351 };
2352 if !file_paths.is_empty() {
2353 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
2354 }
2355 }
2356
2357 fn reveal_in_finder(
2358 &mut self,
2359 _: &RevealInFileManager,
2360 _: &mut Window,
2361 cx: &mut Context<Self>,
2362 ) {
2363 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2364 cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
2365 }
2366 }
2367
2368 fn remove_from_project(
2369 &mut self,
2370 _: &RemoveFromProject,
2371 _window: &mut Window,
2372 cx: &mut Context<Self>,
2373 ) {
2374 for entry in self.effective_entries().iter() {
2375 let worktree_id = entry.worktree_id;
2376 self.project
2377 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
2378 }
2379 }
2380
2381 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
2382 if let Some((worktree, entry)) = self.selected_entry(cx) {
2383 let abs_path = worktree.abs_path().join(&entry.path);
2384 cx.open_with_system(&abs_path);
2385 }
2386 }
2387
2388 fn open_in_terminal(
2389 &mut self,
2390 _: &OpenInTerminal,
2391 window: &mut Window,
2392 cx: &mut Context<Self>,
2393 ) {
2394 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2395 let abs_path = match &entry.canonical_path {
2396 Some(canonical_path) => Some(canonical_path.to_path_buf()),
2397 None => worktree.read(cx).absolutize(&entry.path).ok(),
2398 };
2399
2400 let working_directory = if entry.is_dir() {
2401 abs_path
2402 } else {
2403 abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
2404 };
2405 if let Some(working_directory) = working_directory {
2406 window.dispatch_action(
2407 workspace::OpenTerminal { working_directory }.boxed_clone(),
2408 cx,
2409 )
2410 }
2411 }
2412 }
2413
2414 pub fn new_search_in_directory(
2415 &mut self,
2416 _: &NewSearchInDirectory,
2417 window: &mut Window,
2418 cx: &mut Context<Self>,
2419 ) {
2420 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2421 let dir_path = if entry.is_dir() {
2422 entry.path.clone()
2423 } else {
2424 // entry is a file, use its parent directory
2425 match entry.path.parent() {
2426 Some(parent) => Arc::from(parent),
2427 None => {
2428 // File at root, open search with empty filter
2429 self.workspace
2430 .update(cx, |workspace, cx| {
2431 search::ProjectSearchView::new_search_in_directory(
2432 workspace,
2433 Path::new(""),
2434 window,
2435 cx,
2436 );
2437 })
2438 .ok();
2439 return;
2440 }
2441 }
2442 };
2443
2444 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2445 let dir_path = if include_root {
2446 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2447 full_path.push(&dir_path);
2448 Arc::from(full_path)
2449 } else {
2450 dir_path
2451 };
2452
2453 self.workspace
2454 .update(cx, |workspace, cx| {
2455 search::ProjectSearchView::new_search_in_directory(
2456 workspace, &dir_path, window, cx,
2457 );
2458 })
2459 .ok();
2460 }
2461 }
2462
2463 fn move_entry(
2464 &mut self,
2465 entry_to_move: ProjectEntryId,
2466 destination: ProjectEntryId,
2467 destination_is_file: bool,
2468 cx: &mut Context<Self>,
2469 ) {
2470 if self
2471 .project
2472 .read(cx)
2473 .entry_is_worktree_root(entry_to_move, cx)
2474 {
2475 self.move_worktree_root(entry_to_move, destination, cx)
2476 } else {
2477 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2478 }
2479 }
2480
2481 fn move_worktree_root(
2482 &mut self,
2483 entry_to_move: ProjectEntryId,
2484 destination: ProjectEntryId,
2485 cx: &mut Context<Self>,
2486 ) {
2487 self.project.update(cx, |project, cx| {
2488 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2489 return;
2490 };
2491 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2492 return;
2493 };
2494
2495 let worktree_id = worktree_to_move.read(cx).id();
2496 let destination_id = destination_worktree.read(cx).id();
2497
2498 project
2499 .move_worktree(worktree_id, destination_id, cx)
2500 .log_err();
2501 });
2502 }
2503
2504 fn move_worktree_entry(
2505 &mut self,
2506 entry_to_move: ProjectEntryId,
2507 destination: ProjectEntryId,
2508 destination_is_file: bool,
2509 cx: &mut Context<Self>,
2510 ) {
2511 if entry_to_move == destination {
2512 return;
2513 }
2514
2515 let destination_worktree = self.project.update(cx, |project, cx| {
2516 let entry_path = project.path_for_entry(entry_to_move, cx)?;
2517 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2518
2519 let mut destination_path = destination_entry_path.as_ref();
2520 if destination_is_file {
2521 destination_path = destination_path.parent()?;
2522 }
2523
2524 let mut new_path = destination_path.to_path_buf();
2525 new_path.push(entry_path.path.file_name()?);
2526 if new_path != entry_path.path.as_ref() {
2527 let task = project.rename_entry(entry_to_move, new_path, cx);
2528 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2529 }
2530
2531 project.worktree_id_for_entry(destination, cx)
2532 });
2533
2534 if let Some(destination_worktree) = destination_worktree {
2535 self.expand_entry(destination_worktree, destination, cx);
2536 }
2537 }
2538
2539 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2540 let mut entry_index = 0;
2541 let mut visible_entries_index = 0;
2542 for (worktree_index, (worktree_id, worktree_entries, _)) in
2543 self.visible_entries.iter().enumerate()
2544 {
2545 if *worktree_id == selection.worktree_id {
2546 for entry in worktree_entries {
2547 if entry.id == selection.entry_id {
2548 return Some((worktree_index, entry_index, visible_entries_index));
2549 } else {
2550 visible_entries_index += 1;
2551 entry_index += 1;
2552 }
2553 }
2554 break;
2555 } else {
2556 visible_entries_index += worktree_entries.len();
2557 }
2558 }
2559 None
2560 }
2561
2562 fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
2563 let marked_entries = self.effective_entries();
2564 let mut sanitized_entries = BTreeSet::new();
2565 if marked_entries.is_empty() {
2566 return sanitized_entries;
2567 }
2568
2569 let project = self.project.read(cx);
2570 let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2571 .into_iter()
2572 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2573 .fold(HashMap::default(), |mut map, entry| {
2574 map.entry(entry.worktree_id).or_default().push(entry);
2575 map
2576 });
2577
2578 for (worktree_id, marked_entries) in marked_entries_by_worktree {
2579 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2580 let worktree = worktree.read(cx);
2581 let marked_dir_paths = marked_entries
2582 .iter()
2583 .filter_map(|entry| {
2584 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2585 if entry.is_dir() {
2586 Some(entry.path.as_ref())
2587 } else {
2588 None
2589 }
2590 })
2591 })
2592 .collect::<BTreeSet<_>>();
2593
2594 sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2595 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2596 return false;
2597 };
2598 let entry_path = entry_info.path.as_ref();
2599 let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2600 entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2601 });
2602 !inside_marked_dir
2603 }));
2604 }
2605 }
2606
2607 sanitized_entries
2608 }
2609
2610 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2611 if let Some(selection) = self.selection {
2612 let selection = SelectedEntry {
2613 entry_id: self.resolve_entry(selection.entry_id),
2614 worktree_id: selection.worktree_id,
2615 };
2616
2617 // Default to using just the selected item when nothing is marked.
2618 if self.marked_entries.is_empty() {
2619 return BTreeSet::from([selection]);
2620 }
2621
2622 // Allow operating on the selected item even when something else is marked,
2623 // making it easier to perform one-off actions without clearing a mark.
2624 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2625 return BTreeSet::from([selection]);
2626 }
2627 }
2628
2629 // Return only marked entries since we've already handled special cases where
2630 // only selection should take precedence. At this point, marked entries may or
2631 // may not include the current selection, which is intentional.
2632 self.marked_entries
2633 .iter()
2634 .map(|entry| SelectedEntry {
2635 entry_id: self.resolve_entry(entry.entry_id),
2636 worktree_id: entry.worktree_id,
2637 })
2638 .collect::<BTreeSet<_>>()
2639 }
2640
2641 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2642 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2643 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2644 self.ancestors
2645 .get(&id)
2646 .and_then(|ancestors| {
2647 if ancestors.current_ancestor_depth == 0 {
2648 return None;
2649 }
2650 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2651 })
2652 .copied()
2653 .unwrap_or(id)
2654 }
2655
2656 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
2657 let (worktree, entry) = self.selected_entry_handle(cx)?;
2658 Some((worktree.read(cx), entry))
2659 }
2660
2661 /// Compared to selected_entry, this function resolves to the currently
2662 /// selected subentry if dir auto-folding is enabled.
2663 fn selected_sub_entry<'a>(
2664 &self,
2665 cx: &'a App,
2666 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2667 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2668
2669 let resolved_id = self.resolve_entry(entry.id);
2670 if resolved_id != entry.id {
2671 let worktree = worktree.read(cx);
2672 entry = worktree.entry_for_id(resolved_id)?;
2673 }
2674 Some((worktree, entry))
2675 }
2676 fn selected_entry_handle<'a>(
2677 &self,
2678 cx: &'a App,
2679 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2680 let selection = self.selection?;
2681 let project = self.project.read(cx);
2682 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2683 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2684 Some((worktree, entry))
2685 }
2686
2687 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
2688 let (worktree, entry) = self.selected_entry(cx)?;
2689 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2690
2691 for path in entry.path.ancestors() {
2692 let Some(entry) = worktree.entry_for_path(path) else {
2693 continue;
2694 };
2695 if entry.is_dir() {
2696 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2697 expanded_dir_ids.insert(idx, entry.id);
2698 }
2699 }
2700 }
2701
2702 Some(())
2703 }
2704
2705 fn update_visible_entries(
2706 &mut self,
2707 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2708 cx: &mut Context<Self>,
2709 ) {
2710 let settings = ProjectPanelSettings::get_global(cx);
2711 let auto_collapse_dirs = settings.auto_fold_dirs;
2712 let hide_gitignore = settings.hide_gitignore;
2713 let project = self.project.read(cx);
2714 let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
2715 self.last_worktree_root_id = project
2716 .visible_worktrees(cx)
2717 .next_back()
2718 .and_then(|worktree| worktree.read(cx).root_entry())
2719 .map(|entry| entry.id);
2720
2721 let old_ancestors = std::mem::take(&mut self.ancestors);
2722 self.visible_entries.clear();
2723 let mut max_width_item = None;
2724 for worktree in project.visible_worktrees(cx) {
2725 let worktree_snapshot = worktree.read(cx).snapshot();
2726 let worktree_id = worktree_snapshot.id();
2727
2728 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2729 hash_map::Entry::Occupied(e) => e.into_mut(),
2730 hash_map::Entry::Vacant(e) => {
2731 // The first time a worktree's root entry becomes available,
2732 // mark that root entry as expanded.
2733 if let Some(entry) = worktree_snapshot.root_entry() {
2734 e.insert(vec![entry.id]).as_slice()
2735 } else {
2736 &[]
2737 }
2738 }
2739 };
2740
2741 let mut new_entry_parent_id = None;
2742 let mut new_entry_kind = EntryKind::Dir;
2743 if let Some(edit_state) = &self.edit_state {
2744 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2745 new_entry_parent_id = Some(edit_state.entry_id);
2746 new_entry_kind = if edit_state.is_dir {
2747 EntryKind::Dir
2748 } else {
2749 EntryKind::File
2750 };
2751 }
2752 }
2753
2754 let mut visible_worktree_entries = Vec::new();
2755 let mut entry_iter =
2756 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
2757 let mut auto_folded_ancestors = vec![];
2758 while let Some(entry) = entry_iter.entry() {
2759 if auto_collapse_dirs && entry.kind.is_dir() {
2760 auto_folded_ancestors.push(entry.id);
2761 if !self.unfolded_dir_ids.contains(&entry.id) {
2762 if let Some(root_path) = worktree_snapshot.root_entry() {
2763 let mut child_entries = worktree_snapshot.child_entries(&entry.path);
2764 if let Some(child) = child_entries.next() {
2765 if entry.path != root_path.path
2766 && child_entries.next().is_none()
2767 && child.kind.is_dir()
2768 {
2769 entry_iter.advance();
2770
2771 continue;
2772 }
2773 }
2774 }
2775 }
2776 let depth = old_ancestors
2777 .get(&entry.id)
2778 .map(|ancestor| ancestor.current_ancestor_depth)
2779 .unwrap_or_default()
2780 .min(auto_folded_ancestors.len());
2781 if let Some(edit_state) = &mut self.edit_state {
2782 if edit_state.entry_id == entry.id {
2783 edit_state.depth = depth;
2784 }
2785 }
2786 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2787 if ancestors.len() > 1 {
2788 ancestors.reverse();
2789 self.ancestors.insert(
2790 entry.id,
2791 FoldedAncestors {
2792 current_ancestor_depth: depth,
2793 ancestors,
2794 },
2795 );
2796 }
2797 }
2798 auto_folded_ancestors.clear();
2799 if !hide_gitignore || !entry.is_ignored {
2800 visible_worktree_entries.push(entry.to_owned());
2801 }
2802 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2803 entry.id == new_entry_id || {
2804 self.ancestors.get(&entry.id).map_or(false, |entries| {
2805 entries
2806 .ancestors
2807 .iter()
2808 .any(|entry_id| *entry_id == new_entry_id)
2809 })
2810 }
2811 } else {
2812 false
2813 };
2814 if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) {
2815 visible_worktree_entries.push(GitEntry {
2816 entry: Entry {
2817 id: NEW_ENTRY_ID,
2818 kind: new_entry_kind,
2819 path: entry.path.join("\0").into(),
2820 inode: 0,
2821 mtime: entry.mtime,
2822 size: entry.size,
2823 is_ignored: entry.is_ignored,
2824 is_external: false,
2825 is_private: false,
2826 is_always_included: entry.is_always_included,
2827 canonical_path: entry.canonical_path.clone(),
2828 char_bag: entry.char_bag,
2829 is_fifo: entry.is_fifo,
2830 },
2831 git_summary: entry.git_summary,
2832 });
2833 }
2834 let worktree_abs_path = worktree.read(cx).abs_path();
2835 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2836 let Some(path_name) = worktree_abs_path
2837 .file_name()
2838 .with_context(|| {
2839 format!("Worktree abs path has no file name, root entry: {entry:?}")
2840 })
2841 .log_err()
2842 else {
2843 continue;
2844 };
2845 let path = ArcCow::Borrowed(Path::new(path_name));
2846 let depth = 0;
2847 (depth, path)
2848 } else if entry.is_file() {
2849 let Some(path_name) = entry
2850 .path
2851 .file_name()
2852 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2853 .log_err()
2854 else {
2855 continue;
2856 };
2857 let path = ArcCow::Borrowed(Path::new(path_name));
2858 let depth = entry.path.ancestors().count() - 1;
2859 (depth, path)
2860 } else {
2861 let path = self
2862 .ancestors
2863 .get(&entry.id)
2864 .and_then(|ancestors| {
2865 let outermost_ancestor = ancestors.ancestors.last()?;
2866 let root_folded_entry = worktree
2867 .read(cx)
2868 .entry_for_id(*outermost_ancestor)?
2869 .path
2870 .as_ref();
2871 entry
2872 .path
2873 .strip_prefix(root_folded_entry)
2874 .ok()
2875 .and_then(|suffix| {
2876 let full_path = Path::new(root_folded_entry.file_name()?);
2877 Some(ArcCow::Owned(Arc::<Path>::from(full_path.join(suffix))))
2878 })
2879 })
2880 .or_else(|| entry.path.file_name().map(Path::new).map(ArcCow::Borrowed))
2881 .unwrap_or_else(|| ArcCow::Owned(entry.path.clone()));
2882 let depth = path.components().count();
2883 (depth, path)
2884 };
2885 let width_estimate = item_width_estimate(
2886 depth,
2887 path.to_string_lossy().chars().count(),
2888 entry.canonical_path.is_some(),
2889 );
2890
2891 match max_width_item.as_mut() {
2892 Some((id, worktree_id, width)) => {
2893 if *width < width_estimate {
2894 *id = entry.id;
2895 *worktree_id = worktree.read(cx).id();
2896 *width = width_estimate;
2897 }
2898 }
2899 None => {
2900 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2901 }
2902 }
2903
2904 if expanded_dir_ids.binary_search(&entry.id).is_err()
2905 && entry_iter.advance_to_sibling()
2906 {
2907 continue;
2908 }
2909 entry_iter.advance();
2910 }
2911
2912 project::sort_worktree_entries(&mut visible_worktree_entries);
2913
2914 self.visible_entries
2915 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2916 }
2917
2918 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2919 let mut visited_worktrees_length = 0;
2920 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2921 if worktree_id == *id {
2922 entries
2923 .iter()
2924 .position(|entry| entry.id == project_entry_id)
2925 } else {
2926 visited_worktrees_length += entries.len();
2927 None
2928 }
2929 });
2930 if let Some(index) = index {
2931 self.max_width_item_index = Some(visited_worktrees_length + index);
2932 }
2933 }
2934 if let Some((worktree_id, entry_id)) = new_selected_entry {
2935 self.selection = Some(SelectedEntry {
2936 worktree_id,
2937 entry_id,
2938 });
2939 }
2940 }
2941
2942 fn expand_entry(
2943 &mut self,
2944 worktree_id: WorktreeId,
2945 entry_id: ProjectEntryId,
2946 cx: &mut Context<Self>,
2947 ) {
2948 self.project.update(cx, |project, cx| {
2949 if let Some((worktree, expanded_dir_ids)) = project
2950 .worktree_for_id(worktree_id, cx)
2951 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2952 {
2953 project.expand_entry(worktree_id, entry_id, cx);
2954 let worktree = worktree.read(cx);
2955
2956 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2957 loop {
2958 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2959 expanded_dir_ids.insert(ix, entry.id);
2960 }
2961
2962 if let Some(parent_entry) =
2963 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2964 {
2965 entry = parent_entry;
2966 } else {
2967 break;
2968 }
2969 }
2970 }
2971 }
2972 });
2973 }
2974
2975 fn drop_external_files(
2976 &mut self,
2977 paths: &[PathBuf],
2978 entry_id: ProjectEntryId,
2979 window: &mut Window,
2980 cx: &mut Context<Self>,
2981 ) {
2982 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2983
2984 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2985
2986 let Some((target_directory, worktree)) = maybe!({
2987 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2988 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2989 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2990 let target_directory = if path.is_dir() {
2991 path
2992 } else {
2993 path.parent()?.to_path_buf()
2994 };
2995 Some((target_directory, worktree))
2996 }) else {
2997 return;
2998 };
2999
3000 let mut paths_to_replace = Vec::new();
3001 for path in &paths {
3002 if let Some(name) = path.file_name() {
3003 let mut target_path = target_directory.clone();
3004 target_path.push(name);
3005 if target_path.exists() {
3006 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
3007 }
3008 }
3009 }
3010
3011 cx.spawn_in(window, async move |this, cx| {
3012 async move {
3013 for (filename, original_path) in &paths_to_replace {
3014 let answer = cx.update(|window, cx| {
3015 window
3016 .prompt(
3017 PromptLevel::Info,
3018 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
3019 None,
3020 &["Replace", "Cancel"],
3021 cx,
3022 )
3023 })?.await?;
3024
3025 if answer == 1 {
3026 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
3027 paths.remove(item_idx);
3028 }
3029 }
3030 }
3031
3032 if paths.is_empty() {
3033 return Ok(());
3034 }
3035
3036 let task = worktree.update( cx, |worktree, cx| {
3037 worktree.copy_external_entries(target_directory, paths, true, cx)
3038 })?;
3039
3040 let opened_entries = task.await?;
3041 this.update(cx, |this, cx| {
3042 if open_file_after_drop && !opened_entries.is_empty() {
3043 this.open_entry(opened_entries[0], true, false, cx);
3044 }
3045 })
3046 }
3047 .log_err().await
3048 })
3049 .detach();
3050 }
3051
3052 fn drag_onto(
3053 &mut self,
3054 selections: &DraggedSelection,
3055 target_entry_id: ProjectEntryId,
3056 is_file: bool,
3057 window: &mut Window,
3058 cx: &mut Context<Self>,
3059 ) {
3060 let should_copy = window.modifiers().alt;
3061 if should_copy {
3062 let _ = maybe!({
3063 let project = self.project.read(cx);
3064 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
3065 let worktree_id = target_worktree.read(cx).id();
3066 let target_entry = target_worktree
3067 .read(cx)
3068 .entry_for_id(target_entry_id)?
3069 .clone();
3070
3071 let mut copy_tasks = Vec::new();
3072 let mut disambiguation_range = None;
3073 for selection in selections.items() {
3074 let (new_path, new_disambiguation_range) = self.create_paste_path(
3075 selection,
3076 (target_worktree.clone(), &target_entry),
3077 cx,
3078 )?;
3079
3080 let task = self.project.update(cx, |project, cx| {
3081 project.copy_entry(selection.entry_id, None, new_path, cx)
3082 });
3083 copy_tasks.push(task);
3084 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3085 }
3086
3087 let item_count = copy_tasks.len();
3088
3089 cx.spawn_in(window, async move |project_panel, cx| {
3090 let mut last_succeed = None;
3091 for task in copy_tasks.into_iter() {
3092 if let Some(Some(entry)) = task.await.log_err() {
3093 last_succeed = Some(entry.id);
3094 }
3095 }
3096 // update selection
3097 if let Some(entry_id) = last_succeed {
3098 project_panel
3099 .update_in(cx, |project_panel, window, cx| {
3100 project_panel.selection = Some(SelectedEntry {
3101 worktree_id,
3102 entry_id,
3103 });
3104
3105 // if only one entry was dragged and it was disambiguated, open the rename editor
3106 if item_count == 1 && disambiguation_range.is_some() {
3107 project_panel.rename_impl(disambiguation_range, window, cx);
3108 }
3109 })
3110 .ok();
3111 }
3112 })
3113 .detach();
3114 Some(())
3115 });
3116 } else {
3117 for selection in selections.items() {
3118 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
3119 }
3120 }
3121 }
3122
3123 fn index_for_entry(
3124 &self,
3125 entry_id: ProjectEntryId,
3126 worktree_id: WorktreeId,
3127 ) -> Option<(usize, usize, usize)> {
3128 let mut worktree_ix = 0;
3129 let mut total_ix = 0;
3130 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3131 if worktree_id != *current_worktree_id {
3132 total_ix += visible_worktree_entries.len();
3133 worktree_ix += 1;
3134 continue;
3135 }
3136
3137 return visible_worktree_entries
3138 .iter()
3139 .enumerate()
3140 .find(|(_, entry)| entry.id == entry_id)
3141 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
3142 }
3143 None
3144 }
3145
3146 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
3147 let mut offset = 0;
3148 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3149 if visible_worktree_entries.len() > offset + index {
3150 return visible_worktree_entries
3151 .get(index)
3152 .map(|entry| (*worktree_id, entry.to_ref()));
3153 }
3154 offset += visible_worktree_entries.len();
3155 }
3156 None
3157 }
3158
3159 fn iter_visible_entries(
3160 &self,
3161 range: Range<usize>,
3162 window: &mut Window,
3163 cx: &mut Context<ProjectPanel>,
3164 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
3165 ) {
3166 let mut ix = 0;
3167 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3168 if ix >= range.end {
3169 return;
3170 }
3171
3172 if ix + visible_worktree_entries.len() <= range.start {
3173 ix += visible_worktree_entries.len();
3174 continue;
3175 }
3176
3177 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3178 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3179 let entries = entries_paths.get_or_init(|| {
3180 visible_worktree_entries
3181 .iter()
3182 .map(|e| (e.path.clone()))
3183 .collect()
3184 });
3185 for entry in visible_worktree_entries[entry_range].iter() {
3186 callback(&entry, entries, window, cx);
3187 }
3188 ix = end_ix;
3189 }
3190 }
3191
3192 fn for_each_visible_entry(
3193 &self,
3194 range: Range<usize>,
3195 window: &mut Window,
3196 cx: &mut Context<ProjectPanel>,
3197 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3198 ) {
3199 let mut ix = 0;
3200 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3201 if ix >= range.end {
3202 return;
3203 }
3204
3205 if ix + visible_worktree_entries.len() <= range.start {
3206 ix += visible_worktree_entries.len();
3207 continue;
3208 }
3209
3210 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3211 let (git_status_setting, show_file_icons, show_folder_icons) = {
3212 let settings = ProjectPanelSettings::get_global(cx);
3213 (
3214 settings.git_status,
3215 settings.file_icons,
3216 settings.folder_icons,
3217 )
3218 };
3219 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3220 let snapshot = worktree.read(cx).snapshot();
3221 let root_name = OsStr::new(snapshot.root_name());
3222 let expanded_entry_ids = self
3223 .expanded_dir_ids
3224 .get(&snapshot.id())
3225 .map(Vec::as_slice)
3226 .unwrap_or(&[]);
3227
3228 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3229 let entries = entries_paths.get_or_init(|| {
3230 visible_worktree_entries
3231 .iter()
3232 .map(|e| (e.path.clone()))
3233 .collect()
3234 });
3235 for entry in visible_worktree_entries[entry_range].iter() {
3236 let status = git_status_setting
3237 .then_some(entry.git_summary)
3238 .unwrap_or_default();
3239 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3240 let icon = match entry.kind {
3241 EntryKind::File => {
3242 if show_file_icons {
3243 FileIcons::get_icon(&entry.path, cx)
3244 } else {
3245 None
3246 }
3247 }
3248 _ => {
3249 if show_folder_icons {
3250 FileIcons::get_folder_icon(is_expanded, cx)
3251 } else {
3252 FileIcons::get_chevron_icon(is_expanded, cx)
3253 }
3254 }
3255 };
3256
3257 let (depth, difference) =
3258 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3259
3260 let filename = match difference {
3261 diff if diff > 1 => entry
3262 .path
3263 .iter()
3264 .skip(entry.path.components().count() - diff)
3265 .collect::<PathBuf>()
3266 .to_str()
3267 .unwrap_or_default()
3268 .to_string(),
3269 _ => entry
3270 .path
3271 .file_name()
3272 .map(|name| name.to_string_lossy().into_owned())
3273 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3274 };
3275 let selection = SelectedEntry {
3276 worktree_id: snapshot.id(),
3277 entry_id: entry.id,
3278 };
3279
3280 let is_marked = self.marked_entries.contains(&selection);
3281
3282 let diagnostic_severity = self
3283 .diagnostics
3284 .get(&(*worktree_id, entry.path.to_path_buf()))
3285 .cloned();
3286
3287 let filename_text_color =
3288 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3289
3290 let mut details = EntryDetails {
3291 filename,
3292 icon,
3293 path: entry.path.clone(),
3294 depth,
3295 kind: entry.kind,
3296 is_ignored: entry.is_ignored,
3297 is_expanded,
3298 is_selected: self.selection == Some(selection),
3299 is_marked,
3300 is_editing: false,
3301 is_processing: false,
3302 is_cut: self
3303 .clipboard
3304 .as_ref()
3305 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3306 filename_text_color,
3307 diagnostic_severity,
3308 git_status: status,
3309 is_private: entry.is_private,
3310 worktree_id: *worktree_id,
3311 canonical_path: entry.canonical_path.clone(),
3312 };
3313
3314 if let Some(edit_state) = &self.edit_state {
3315 let is_edited_entry = if edit_state.is_new_entry() {
3316 entry.id == NEW_ENTRY_ID
3317 } else {
3318 entry.id == edit_state.entry_id
3319 || self
3320 .ancestors
3321 .get(&entry.id)
3322 .is_some_and(|auto_folded_dirs| {
3323 auto_folded_dirs
3324 .ancestors
3325 .iter()
3326 .any(|entry_id| *entry_id == edit_state.entry_id)
3327 })
3328 };
3329
3330 if is_edited_entry {
3331 if let Some(processing_filename) = &edit_state.processing_filename {
3332 details.is_processing = true;
3333 if let Some(ancestors) = edit_state
3334 .leaf_entry_id
3335 .and_then(|entry| self.ancestors.get(&entry))
3336 {
3337 let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
3338 let all_components = ancestors.ancestors.len();
3339
3340 let prefix_components = all_components - position;
3341 let suffix_components = position.checked_sub(1);
3342 let mut previous_components =
3343 Path::new(&details.filename).components();
3344 let mut new_path = previous_components
3345 .by_ref()
3346 .take(prefix_components)
3347 .collect::<PathBuf>();
3348 if let Some(last_component) =
3349 Path::new(processing_filename).components().next_back()
3350 {
3351 new_path.push(last_component);
3352 previous_components.next();
3353 }
3354
3355 if let Some(_) = suffix_components {
3356 new_path.push(previous_components);
3357 }
3358 if let Some(str) = new_path.to_str() {
3359 details.filename.clear();
3360 details.filename.push_str(str);
3361 }
3362 } else {
3363 details.filename.clear();
3364 details.filename.push_str(processing_filename);
3365 }
3366 } else {
3367 if edit_state.is_new_entry() {
3368 details.filename.clear();
3369 }
3370 details.is_editing = true;
3371 }
3372 }
3373 }
3374
3375 callback(entry.id, details, window, cx);
3376 }
3377 }
3378 ix = end_ix;
3379 }
3380 }
3381
3382 fn find_entry_in_worktree(
3383 &self,
3384 worktree_id: WorktreeId,
3385 reverse_search: bool,
3386 only_visible_entries: bool,
3387 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3388 cx: &mut Context<Self>,
3389 ) -> Option<GitEntry> {
3390 if only_visible_entries {
3391 let entries = self
3392 .visible_entries
3393 .iter()
3394 .find_map(|(tree_id, entries, _)| {
3395 if worktree_id == *tree_id {
3396 Some(entries)
3397 } else {
3398 None
3399 }
3400 })?
3401 .clone();
3402
3403 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3404 .find(|ele| predicate(ele.to_ref(), worktree_id))
3405 .cloned();
3406 }
3407
3408 let repo_snapshots = self
3409 .project
3410 .read(cx)
3411 .git_store()
3412 .read(cx)
3413 .repo_snapshots(cx);
3414 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3415 worktree.update(cx, |tree, _| {
3416 utils::ReversibleIterable::new(
3417 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
3418 reverse_search,
3419 )
3420 .find_single_ended(|ele| predicate(*ele, worktree_id))
3421 .map(|ele| ele.to_owned())
3422 })
3423 }
3424
3425 fn find_entry(
3426 &self,
3427 start: Option<&SelectedEntry>,
3428 reverse_search: bool,
3429 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3430 cx: &mut Context<Self>,
3431 ) -> Option<SelectedEntry> {
3432 let mut worktree_ids: Vec<_> = self
3433 .visible_entries
3434 .iter()
3435 .map(|(worktree_id, _, _)| *worktree_id)
3436 .collect();
3437 let repo_snapshots = self
3438 .project
3439 .read(cx)
3440 .git_store()
3441 .read(cx)
3442 .repo_snapshots(cx);
3443
3444 let mut last_found: Option<SelectedEntry> = None;
3445
3446 if let Some(start) = start {
3447 let worktree = self
3448 .project
3449 .read(cx)
3450 .worktree_for_id(start.worktree_id, cx)?;
3451
3452 let search = worktree.update(cx, |tree, _| {
3453 let entry = tree.entry_for_id(start.entry_id)?;
3454 let root_entry = tree.root_entry()?;
3455 let tree_id = tree.id();
3456
3457 let mut first_iter = GitTraversal::new(
3458 &repo_snapshots,
3459 tree.traverse_from_path(true, true, true, entry.path.as_ref()),
3460 );
3461
3462 if reverse_search {
3463 first_iter.next();
3464 }
3465
3466 let first = first_iter
3467 .enumerate()
3468 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3469 .map(|(_, entry)| entry)
3470 .find(|ele| predicate(*ele, tree_id))
3471 .map(|ele| ele.to_owned());
3472
3473 let second_iter = GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize));
3474
3475 let second = if reverse_search {
3476 second_iter
3477 .take_until(|ele| ele.id == start.entry_id)
3478 .filter(|ele| predicate(*ele, tree_id))
3479 .last()
3480 .map(|ele| ele.to_owned())
3481 } else {
3482 second_iter
3483 .take_while(|ele| ele.id != start.entry_id)
3484 .filter(|ele| predicate(*ele, tree_id))
3485 .last()
3486 .map(|ele| ele.to_owned())
3487 };
3488
3489 if reverse_search {
3490 Some((second, first))
3491 } else {
3492 Some((first, second))
3493 }
3494 });
3495
3496 if let Some((first, second)) = search {
3497 let first = first.map(|entry| SelectedEntry {
3498 worktree_id: start.worktree_id,
3499 entry_id: entry.id,
3500 });
3501
3502 let second = second.map(|entry| SelectedEntry {
3503 worktree_id: start.worktree_id,
3504 entry_id: entry.id,
3505 });
3506
3507 if first.is_some() {
3508 return first;
3509 }
3510 last_found = second;
3511
3512 let idx = worktree_ids
3513 .iter()
3514 .enumerate()
3515 .find(|(_, ele)| **ele == start.worktree_id)
3516 .map(|(idx, _)| idx);
3517
3518 if let Some(idx) = idx {
3519 worktree_ids.rotate_left(idx + 1usize);
3520 worktree_ids.pop();
3521 }
3522 }
3523 }
3524
3525 for tree_id in worktree_ids.into_iter() {
3526 if let Some(found) =
3527 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3528 {
3529 return Some(SelectedEntry {
3530 worktree_id: tree_id,
3531 entry_id: found.id,
3532 });
3533 }
3534 }
3535
3536 last_found
3537 }
3538
3539 fn find_visible_entry(
3540 &self,
3541 start: Option<&SelectedEntry>,
3542 reverse_search: bool,
3543 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3544 cx: &mut Context<Self>,
3545 ) -> Option<SelectedEntry> {
3546 let mut worktree_ids: Vec<_> = self
3547 .visible_entries
3548 .iter()
3549 .map(|(worktree_id, _, _)| *worktree_id)
3550 .collect();
3551
3552 let mut last_found: Option<SelectedEntry> = None;
3553
3554 if let Some(start) = start {
3555 let entries = self
3556 .visible_entries
3557 .iter()
3558 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3559 .map(|(_, entries, _)| entries)?;
3560
3561 let mut start_idx = entries
3562 .iter()
3563 .enumerate()
3564 .find(|(_, ele)| ele.id == start.entry_id)
3565 .map(|(idx, _)| idx)?;
3566
3567 if reverse_search {
3568 start_idx = start_idx.saturating_add(1usize);
3569 }
3570
3571 let (left, right) = entries.split_at_checked(start_idx)?;
3572
3573 let (first_iter, second_iter) = if reverse_search {
3574 (
3575 utils::ReversibleIterable::new(left.iter(), reverse_search),
3576 utils::ReversibleIterable::new(right.iter(), reverse_search),
3577 )
3578 } else {
3579 (
3580 utils::ReversibleIterable::new(right.iter(), reverse_search),
3581 utils::ReversibleIterable::new(left.iter(), reverse_search),
3582 )
3583 };
3584
3585 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3586 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3587
3588 if first_search.is_some() {
3589 return first_search.map(|entry| SelectedEntry {
3590 worktree_id: start.worktree_id,
3591 entry_id: entry.id,
3592 });
3593 }
3594
3595 last_found = second_search.map(|entry| SelectedEntry {
3596 worktree_id: start.worktree_id,
3597 entry_id: entry.id,
3598 });
3599
3600 let idx = worktree_ids
3601 .iter()
3602 .enumerate()
3603 .find(|(_, ele)| **ele == start.worktree_id)
3604 .map(|(idx, _)| idx);
3605
3606 if let Some(idx) = idx {
3607 worktree_ids.rotate_left(idx + 1usize);
3608 worktree_ids.pop();
3609 }
3610 }
3611
3612 for tree_id in worktree_ids.into_iter() {
3613 if let Some(found) =
3614 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3615 {
3616 return Some(SelectedEntry {
3617 worktree_id: tree_id,
3618 entry_id: found.id,
3619 });
3620 }
3621 }
3622
3623 last_found
3624 }
3625
3626 fn calculate_depth_and_difference(
3627 entry: &Entry,
3628 visible_worktree_entries: &HashSet<Arc<Path>>,
3629 ) -> (usize, usize) {
3630 let (depth, difference) = entry
3631 .path
3632 .ancestors()
3633 .skip(1) // Skip the entry itself
3634 .find_map(|ancestor| {
3635 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3636 let entry_path_components_count = entry.path.components().count();
3637 let parent_path_components_count = parent_entry.components().count();
3638 let difference = entry_path_components_count - parent_path_components_count;
3639 let depth = parent_entry
3640 .ancestors()
3641 .skip(1)
3642 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3643 .count();
3644 Some((depth + 1, difference))
3645 } else {
3646 None
3647 }
3648 })
3649 .unwrap_or((0, 0));
3650
3651 (depth, difference)
3652 }
3653
3654 fn render_entry(
3655 &self,
3656 entry_id: ProjectEntryId,
3657 details: EntryDetails,
3658 window: &mut Window,
3659 cx: &mut Context<Self>,
3660 ) -> Stateful<Div> {
3661 const GROUP_NAME: &str = "project_entry";
3662
3663 let kind = details.kind;
3664 let settings = ProjectPanelSettings::get_global(cx);
3665 let show_editor = details.is_editing && !details.is_processing;
3666
3667 let selection = SelectedEntry {
3668 worktree_id: details.worktree_id,
3669 entry_id,
3670 };
3671
3672 let is_marked = self.marked_entries.contains(&selection);
3673 let is_active = self
3674 .selection
3675 .map_or(false, |selection| selection.entry_id == entry_id);
3676
3677 let file_name = details.filename.clone();
3678
3679 let mut icon = details.icon.clone();
3680 if settings.file_icons && show_editor && details.kind.is_file() {
3681 let filename = self.filename_editor.read(cx).text(cx);
3682 if filename.len() > 2 {
3683 icon = FileIcons::get_icon(Path::new(&filename), cx);
3684 }
3685 }
3686
3687 let filename_text_color = details.filename_text_color;
3688 let diagnostic_severity = details.diagnostic_severity;
3689 let item_colors = get_item_color(cx);
3690
3691 let canonical_path = details
3692 .canonical_path
3693 .as_ref()
3694 .map(|f| f.to_string_lossy().to_string());
3695 let path = details.path.clone();
3696
3697 let depth = details.depth;
3698 let worktree_id = details.worktree_id;
3699 let selections = Arc::new(self.marked_entries.clone());
3700 let is_local = self.project.read(cx).is_local();
3701
3702 let dragged_selection = DraggedSelection {
3703 active_selection: selection,
3704 marked_selections: selections,
3705 };
3706
3707 let bg_color = if is_marked {
3708 item_colors.marked
3709 } else {
3710 item_colors.default
3711 };
3712
3713 let bg_hover_color = if is_marked {
3714 item_colors.marked
3715 } else {
3716 item_colors.hover
3717 };
3718
3719 let validation_color_and_message = if show_editor {
3720 match self
3721 .edit_state
3722 .as_ref()
3723 .map_or(ValidationState::None, |e| e.validation_state.clone())
3724 {
3725 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg.clone())),
3726 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg.clone())),
3727 ValidationState::None => None,
3728 }
3729 } else {
3730 None
3731 };
3732
3733 let border_color =
3734 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3735 match validation_color_and_message {
3736 Some((color, _)) => color,
3737 None => item_colors.focused,
3738 }
3739 } else {
3740 bg_color
3741 };
3742
3743 let border_hover_color =
3744 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3745 match validation_color_and_message {
3746 Some((color, _)) => color,
3747 None => item_colors.focused,
3748 }
3749 } else {
3750 bg_hover_color
3751 };
3752
3753 let folded_directory_drag_target = self.folded_directory_drag_target;
3754
3755 div()
3756 .id(entry_id.to_proto() as usize)
3757 .group(GROUP_NAME)
3758 .cursor_pointer()
3759 .rounded_none()
3760 .bg(bg_color)
3761 .border_1()
3762 .border_r_2()
3763 .border_color(border_color)
3764 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3765 .when(is_local, |div| {
3766 div.on_drag_move::<ExternalPaths>(cx.listener(
3767 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
3768 if event.bounds.contains(&event.event.position) {
3769 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3770 return;
3771 }
3772 this.last_external_paths_drag_over_entry = Some(entry_id);
3773 this.marked_entries.clear();
3774
3775 let Some((worktree, path, entry)) = maybe!({
3776 let worktree = this
3777 .project
3778 .read(cx)
3779 .worktree_for_id(selection.worktree_id, cx)?;
3780 let worktree = worktree.read(cx);
3781 let abs_path = worktree.absolutize(&path).log_err()?;
3782 let path = if abs_path.is_dir() {
3783 path.as_ref()
3784 } else {
3785 path.parent()?
3786 };
3787 let entry = worktree.entry_for_path(path)?;
3788 Some((worktree, path, entry))
3789 }) else {
3790 return;
3791 };
3792
3793 this.marked_entries.insert(SelectedEntry {
3794 entry_id: entry.id,
3795 worktree_id: worktree.id(),
3796 });
3797
3798 for entry in worktree.child_entries(path) {
3799 this.marked_entries.insert(SelectedEntry {
3800 entry_id: entry.id,
3801 worktree_id: worktree.id(),
3802 });
3803 }
3804
3805 cx.notify();
3806 }
3807 },
3808 ))
3809 .on_drop(cx.listener(
3810 move |this, external_paths: &ExternalPaths, window, cx| {
3811 this.hover_scroll_task.take();
3812 this.last_external_paths_drag_over_entry = None;
3813 this.marked_entries.clear();
3814 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3815 cx.stop_propagation();
3816 },
3817 ))
3818 })
3819 .on_drag_move::<DraggedSelection>(cx.listener(
3820 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3821 if event.bounds.contains(&event.event.position) {
3822 if this.last_selection_drag_over_entry == Some(entry_id) {
3823 return;
3824 }
3825 this.last_selection_drag_over_entry = Some(entry_id);
3826 this.hover_expand_task.take();
3827
3828 if !kind.is_dir()
3829 || this
3830 .expanded_dir_ids
3831 .get(&details.worktree_id)
3832 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3833 {
3834 return;
3835 }
3836
3837 let bounds = event.bounds;
3838 this.hover_expand_task =
3839 Some(cx.spawn_in(window, async move |this, cx| {
3840 cx.background_executor()
3841 .timer(Duration::from_millis(500))
3842 .await;
3843 this.update_in(cx, |this, window, cx| {
3844 this.hover_expand_task.take();
3845 if this.last_selection_drag_over_entry == Some(entry_id)
3846 && bounds.contains(&window.mouse_position())
3847 {
3848 this.expand_entry(worktree_id, entry_id, cx);
3849 this.update_visible_entries(
3850 Some((worktree_id, entry_id)),
3851 cx,
3852 );
3853 cx.notify();
3854 }
3855 })
3856 .ok();
3857 }));
3858 }
3859 },
3860 ))
3861 .on_drag(
3862 dragged_selection,
3863 move |selection, click_offset, _window, cx| {
3864 cx.new(|_| DraggedProjectEntryView {
3865 details: details.clone(),
3866 click_offset,
3867 selection: selection.active_selection,
3868 selections: selection.marked_selections.clone(),
3869 })
3870 },
3871 )
3872 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3873 if folded_directory_drag_target.is_some() {
3874 return style;
3875 }
3876 style.bg(item_colors.drag_over)
3877 })
3878 .on_drop(
3879 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3880 this.hover_scroll_task.take();
3881 this.hover_expand_task.take();
3882 if folded_directory_drag_target.is_some() {
3883 return;
3884 }
3885 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3886 }),
3887 )
3888 .on_mouse_down(
3889 MouseButton::Left,
3890 cx.listener(move |this, _, _, cx| {
3891 this.mouse_down = true;
3892 cx.propagate();
3893 }),
3894 )
3895 .on_click(
3896 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3897 if event.down.button == MouseButton::Right
3898 || event.down.first_mouse
3899 || show_editor
3900 {
3901 return;
3902 }
3903 if event.down.button == MouseButton::Left {
3904 this.mouse_down = false;
3905 }
3906 cx.stop_propagation();
3907
3908 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3909 let current_selection = this.index_for_selection(selection);
3910 let clicked_entry = SelectedEntry {
3911 entry_id,
3912 worktree_id,
3913 };
3914 let target_selection = this.index_for_selection(clicked_entry);
3915 if let Some(((_, _, source_index), (_, _, target_index))) =
3916 current_selection.zip(target_selection)
3917 {
3918 let range_start = source_index.min(target_index);
3919 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3920 let mut new_selections = BTreeSet::new();
3921 this.for_each_visible_entry(
3922 range_start..range_end,
3923 window,
3924 cx,
3925 |entry_id, details, _, _| {
3926 new_selections.insert(SelectedEntry {
3927 entry_id,
3928 worktree_id: details.worktree_id,
3929 });
3930 },
3931 );
3932
3933 this.marked_entries = this
3934 .marked_entries
3935 .union(&new_selections)
3936 .cloned()
3937 .collect();
3938
3939 this.selection = Some(clicked_entry);
3940 this.marked_entries.insert(clicked_entry);
3941 }
3942 } else if event.modifiers().secondary() {
3943 if event.down.click_count > 1 {
3944 this.split_entry(entry_id, cx);
3945 } else {
3946 this.selection = Some(selection);
3947 if !this.marked_entries.insert(selection) {
3948 this.marked_entries.remove(&selection);
3949 }
3950 }
3951 } else if kind.is_dir() {
3952 this.marked_entries.clear();
3953 if event.modifiers().alt {
3954 this.toggle_expand_all(entry_id, window, cx);
3955 } else {
3956 this.toggle_expanded(entry_id, window, cx);
3957 }
3958 } else {
3959 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3960 let click_count = event.up.click_count;
3961 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3962 let allow_preview = preview_tabs_enabled && click_count == 1;
3963 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3964 }
3965 }),
3966 )
3967 .child(
3968 ListItem::new(entry_id.to_proto() as usize)
3969 .indent_level(depth)
3970 .indent_step_size(px(settings.indent_size))
3971 .spacing(match settings.entry_spacing {
3972 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3973 project_panel_settings::EntrySpacing::Standard => {
3974 ListItemSpacing::ExtraDense
3975 }
3976 })
3977 .selectable(false)
3978 .when_some(canonical_path, |this, path| {
3979 this.end_slot::<AnyElement>(
3980 div()
3981 .id("symlink_icon")
3982 .pr_3()
3983 .tooltip(move |window, cx| {
3984 Tooltip::with_meta(
3985 path.to_string(),
3986 None,
3987 "Symbolic Link",
3988 window,
3989 cx,
3990 )
3991 })
3992 .child(
3993 Icon::new(IconName::ArrowUpRight)
3994 .size(IconSize::Indicator)
3995 .color(filename_text_color),
3996 )
3997 .into_any_element(),
3998 )
3999 })
4000 .child(if let Some(icon) = &icon {
4001 if let Some((_, decoration_color)) =
4002 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
4003 {
4004 let is_warning = diagnostic_severity
4005 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
4006 .unwrap_or(false);
4007 div().child(
4008 DecoratedIcon::new(
4009 Icon::from_path(icon.clone()).color(Color::Muted),
4010 Some(
4011 IconDecoration::new(
4012 if kind.is_file() {
4013 if is_warning {
4014 IconDecorationKind::Triangle
4015 } else {
4016 IconDecorationKind::X
4017 }
4018 } else {
4019 IconDecorationKind::Dot
4020 },
4021 bg_color,
4022 cx,
4023 )
4024 .group_name(Some(GROUP_NAME.into()))
4025 .knockout_hover_color(bg_hover_color)
4026 .color(decoration_color.color(cx))
4027 .position(Point {
4028 x: px(-2.),
4029 y: px(-2.),
4030 }),
4031 ),
4032 )
4033 .into_any_element(),
4034 )
4035 } else {
4036 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
4037 }
4038 } else {
4039 if let Some((icon_name, color)) =
4040 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
4041 {
4042 h_flex()
4043 .size(IconSize::default().rems())
4044 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
4045 } else {
4046 h_flex()
4047 .size(IconSize::default().rems())
4048 .invisible()
4049 .flex_none()
4050 }
4051 })
4052 .child(
4053 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
4054 h_flex().h_6().w_full().child(editor.clone())
4055 } else {
4056 h_flex().h_6().map(|mut this| {
4057 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
4058 let components = Path::new(&file_name)
4059 .components()
4060 .map(|comp| {
4061 let comp_str =
4062 comp.as_os_str().to_string_lossy().into_owned();
4063 comp_str
4064 })
4065 .collect::<Vec<_>>();
4066
4067 let components_len = components.len();
4068 let active_index = components_len
4069 - 1
4070 - folded_ancestors.current_ancestor_depth;
4071 const DELIMITER: SharedString =
4072 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
4073 for (index, component) in components.into_iter().enumerate() {
4074 if index != 0 {
4075 let delimiter_target_index = index - 1;
4076 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
4077 this = this.child(
4078 div()
4079 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
4080 this.hover_scroll_task.take();
4081 this.folded_directory_drag_target = None;
4082 if let Some(target_entry_id) = target_entry_id {
4083 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4084 }
4085 }))
4086 .on_drag_move(cx.listener(
4087 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4088 if event.bounds.contains(&event.event.position) {
4089 this.folded_directory_drag_target = Some(
4090 FoldedDirectoryDragTarget {
4091 entry_id,
4092 index: delimiter_target_index,
4093 is_delimiter_target: true,
4094 }
4095 );
4096 } else {
4097 let is_current_target = this.folded_directory_drag_target
4098 .map_or(false, |target|
4099 target.entry_id == entry_id &&
4100 target.index == delimiter_target_index &&
4101 target.is_delimiter_target
4102 );
4103 if is_current_target {
4104 this.folded_directory_drag_target = None;
4105 }
4106 }
4107
4108 },
4109 ))
4110 .child(
4111 Label::new(DELIMITER.clone())
4112 .single_line()
4113 .color(filename_text_color)
4114 )
4115 );
4116 }
4117 let id = SharedString::from(format!(
4118 "project_panel_path_component_{}_{index}",
4119 entry_id.to_usize()
4120 ));
4121 let label = div()
4122 .id(id)
4123 .on_click(cx.listener(move |this, _, _, cx| {
4124 if index != active_index {
4125 if let Some(folds) =
4126 this.ancestors.get_mut(&entry_id)
4127 {
4128 folds.current_ancestor_depth =
4129 components_len - 1 - index;
4130 cx.notify();
4131 }
4132 }
4133 }))
4134 .when(index != components_len - 1, |div|{
4135 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
4136 div
4137 .on_drag_move(cx.listener(
4138 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4139 if event.bounds.contains(&event.event.position) {
4140 this.folded_directory_drag_target = Some(
4141 FoldedDirectoryDragTarget {
4142 entry_id,
4143 index,
4144 is_delimiter_target: false,
4145 }
4146 );
4147 } else {
4148 let is_current_target = this.folded_directory_drag_target
4149 .as_ref()
4150 .map_or(false, |target|
4151 target.entry_id == entry_id &&
4152 target.index == index &&
4153 !target.is_delimiter_target
4154 );
4155 if is_current_target {
4156 this.folded_directory_drag_target = None;
4157 }
4158 }
4159 },
4160 ))
4161 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
4162 this.hover_scroll_task.take();
4163 this.folded_directory_drag_target = None;
4164 if let Some(target_entry_id) = target_entry_id {
4165 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4166 }
4167 }))
4168 .when(folded_directory_drag_target.map_or(false, |target|
4169 target.entry_id == entry_id &&
4170 target.index == index
4171 ), |this| {
4172 this.bg(item_colors.drag_over)
4173 })
4174 })
4175 .child(
4176 Label::new(component)
4177 .single_line()
4178 .color(filename_text_color)
4179 .when(
4180 index == active_index
4181 && (is_active || is_marked),
4182 |this| this.underline(),
4183 ),
4184 );
4185
4186 this = this.child(label);
4187 }
4188
4189 this
4190 } else {
4191 this.child(
4192 Label::new(file_name)
4193 .single_line()
4194 .color(filename_text_color),
4195 )
4196 }
4197 })
4198 }
4199 .ml_1(),
4200 )
4201 .on_secondary_mouse_down(cx.listener(
4202 move |this, event: &MouseDownEvent, window, cx| {
4203 // Stop propagation to prevent the catch-all context menu for the project
4204 // panel from being deployed.
4205 cx.stop_propagation();
4206 // Some context menu actions apply to all marked entries. If the user
4207 // right-clicks on an entry that is not marked, they may not realize the
4208 // action applies to multiple entries. To avoid inadvertent changes, all
4209 // entries are unmarked.
4210 if !this.marked_entries.contains(&selection) {
4211 this.marked_entries.clear();
4212 }
4213 this.deploy_context_menu(event.position, entry_id, window, cx);
4214 },
4215 ))
4216 .overflow_x(),
4217 )
4218 .when_some(
4219 validation_color_and_message,
4220 |this, (color, message)| {
4221 this
4222 .relative()
4223 .child(
4224 deferred(
4225 div()
4226 .occlude()
4227 .absolute()
4228 .top_full()
4229 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
4230 .right(px(-0.5))
4231 .py_1()
4232 .px_2()
4233 .border_1()
4234 .border_color(color)
4235 .bg(cx.theme().colors().background)
4236 .child(
4237 Label::new(message)
4238 .color(Color::from(color))
4239 .size(LabelSize::Small)
4240 )
4241 )
4242 )
4243 }
4244 )
4245 }
4246
4247 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4248 if !Self::should_show_scrollbar(cx)
4249 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4250 {
4251 return None;
4252 }
4253 Some(
4254 div()
4255 .occlude()
4256 .id("project-panel-vertical-scroll")
4257 .on_mouse_move(cx.listener(|_, _, _, cx| {
4258 cx.notify();
4259 cx.stop_propagation()
4260 }))
4261 .on_hover(|_, _, cx| {
4262 cx.stop_propagation();
4263 })
4264 .on_any_mouse_down(|_, _, cx| {
4265 cx.stop_propagation();
4266 })
4267 .on_mouse_up(
4268 MouseButton::Left,
4269 cx.listener(|this, _, window, cx| {
4270 if !this.vertical_scrollbar_state.is_dragging()
4271 && !this.focus_handle.contains_focused(window, cx)
4272 {
4273 this.hide_scrollbar(window, cx);
4274 cx.notify();
4275 }
4276
4277 cx.stop_propagation();
4278 }),
4279 )
4280 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4281 cx.notify();
4282 }))
4283 .h_full()
4284 .absolute()
4285 .right_1()
4286 .top_1()
4287 .bottom_1()
4288 .w(px(12.))
4289 .cursor_default()
4290 .children(Scrollbar::vertical(
4291 // percentage as f32..end_offset as f32,
4292 self.vertical_scrollbar_state.clone(),
4293 )),
4294 )
4295 }
4296
4297 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4298 if !Self::should_show_scrollbar(cx)
4299 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4300 {
4301 return None;
4302 }
4303
4304 let scroll_handle = self.scroll_handle.0.borrow();
4305 let longest_item_width = scroll_handle
4306 .last_item_size
4307 .filter(|size| size.contents.width > size.item.width)?
4308 .contents
4309 .width
4310 .0 as f64;
4311 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4312 return None;
4313 }
4314
4315 Some(
4316 div()
4317 .occlude()
4318 .id("project-panel-horizontal-scroll")
4319 .on_mouse_move(cx.listener(|_, _, _, cx| {
4320 cx.notify();
4321 cx.stop_propagation()
4322 }))
4323 .on_hover(|_, _, cx| {
4324 cx.stop_propagation();
4325 })
4326 .on_any_mouse_down(|_, _, cx| {
4327 cx.stop_propagation();
4328 })
4329 .on_mouse_up(
4330 MouseButton::Left,
4331 cx.listener(|this, _, window, cx| {
4332 if !this.horizontal_scrollbar_state.is_dragging()
4333 && !this.focus_handle.contains_focused(window, cx)
4334 {
4335 this.hide_scrollbar(window, cx);
4336 cx.notify();
4337 }
4338
4339 cx.stop_propagation();
4340 }),
4341 )
4342 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4343 cx.notify();
4344 }))
4345 .w_full()
4346 .absolute()
4347 .right_1()
4348 .left_1()
4349 .bottom_1()
4350 .h(px(12.))
4351 .cursor_default()
4352 .when(self.width.is_some(), |this| {
4353 this.children(Scrollbar::horizontal(
4354 self.horizontal_scrollbar_state.clone(),
4355 ))
4356 }),
4357 )
4358 }
4359
4360 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4361 let mut dispatch_context = KeyContext::new_with_defaults();
4362 dispatch_context.add("ProjectPanel");
4363 dispatch_context.add("menu");
4364
4365 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4366 "editing"
4367 } else {
4368 "not_editing"
4369 };
4370
4371 dispatch_context.add(identifier);
4372 dispatch_context
4373 }
4374
4375 fn should_show_scrollbar(cx: &App) -> bool {
4376 let show = ProjectPanelSettings::get_global(cx)
4377 .scrollbar
4378 .show
4379 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4380 match show {
4381 ShowScrollbar::Auto => true,
4382 ShowScrollbar::System => true,
4383 ShowScrollbar::Always => true,
4384 ShowScrollbar::Never => false,
4385 }
4386 }
4387
4388 fn should_autohide_scrollbar(cx: &App) -> bool {
4389 let show = ProjectPanelSettings::get_global(cx)
4390 .scrollbar
4391 .show
4392 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4393 match show {
4394 ShowScrollbar::Auto => true,
4395 ShowScrollbar::System => cx
4396 .try_global::<ScrollbarAutoHide>()
4397 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4398 ShowScrollbar::Always => false,
4399 ShowScrollbar::Never => true,
4400 }
4401 }
4402
4403 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4404 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4405 if !Self::should_autohide_scrollbar(cx) {
4406 return;
4407 }
4408 self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
4409 cx.background_executor()
4410 .timer(SCROLLBAR_SHOW_INTERVAL)
4411 .await;
4412 panel
4413 .update(cx, |panel, cx| {
4414 panel.show_scrollbar = false;
4415 cx.notify();
4416 })
4417 .log_err();
4418 }))
4419 }
4420
4421 fn reveal_entry(
4422 &mut self,
4423 project: Entity<Project>,
4424 entry_id: ProjectEntryId,
4425 skip_ignored: bool,
4426 cx: &mut Context<Self>,
4427 ) {
4428 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4429 let worktree = worktree.read(cx);
4430 if skip_ignored
4431 && worktree
4432 .entry_for_id(entry_id)
4433 .map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
4434 {
4435 return;
4436 }
4437
4438 let worktree_id = worktree.id();
4439 self.expand_entry(worktree_id, entry_id, cx);
4440 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4441 self.marked_entries.clear();
4442 self.marked_entries.insert(SelectedEntry {
4443 worktree_id,
4444 entry_id,
4445 });
4446 self.autoscroll(cx);
4447 cx.notify();
4448 }
4449 }
4450
4451 fn find_active_indent_guide(
4452 &self,
4453 indent_guides: &[IndentGuideLayout],
4454 cx: &App,
4455 ) -> Option<usize> {
4456 let (worktree, entry) = self.selected_entry(cx)?;
4457
4458 // Find the parent entry of the indent guide, this will either be the
4459 // expanded folder we have selected, or the parent of the currently
4460 // selected file/collapsed directory
4461 let mut entry = entry;
4462 loop {
4463 let is_expanded_dir = entry.is_dir()
4464 && self
4465 .expanded_dir_ids
4466 .get(&worktree.id())
4467 .map(|ids| ids.binary_search(&entry.id).is_ok())
4468 .unwrap_or(false);
4469 if is_expanded_dir {
4470 break;
4471 }
4472 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4473 }
4474
4475 let (active_indent_range, depth) = {
4476 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4477 let child_paths = &self.visible_entries[worktree_ix].1;
4478 let mut child_count = 0;
4479 let depth = entry.path.ancestors().count();
4480 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4481 if entry.path.ancestors().count() <= depth {
4482 break;
4483 }
4484 child_count += 1;
4485 }
4486
4487 let start = ix + 1;
4488 let end = start + child_count;
4489
4490 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4491 let visible_worktree_entries =
4492 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4493
4494 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4495 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4496 (start..end, depth)
4497 };
4498
4499 let candidates = indent_guides
4500 .iter()
4501 .enumerate()
4502 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4503
4504 for (i, indent) in candidates {
4505 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4506 if active_indent_range.start <= indent.offset.y + indent.length
4507 && indent.offset.y <= active_indent_range.end
4508 {
4509 return Some(i);
4510 }
4511 }
4512 None
4513 }
4514}
4515
4516fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4517 const ICON_SIZE_FACTOR: usize = 2;
4518 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4519 if is_symlink {
4520 item_width += ICON_SIZE_FACTOR;
4521 }
4522 item_width
4523}
4524
4525impl Render for ProjectPanel {
4526 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4527 let has_worktree = !self.visible_entries.is_empty();
4528 let project = self.project.read(cx);
4529 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4530 let show_indent_guides =
4531 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4532 let is_local = project.is_local();
4533
4534 if has_worktree {
4535 let item_count = self
4536 .visible_entries
4537 .iter()
4538 .map(|(_, worktree_entries, _)| worktree_entries.len())
4539 .sum();
4540
4541 fn handle_drag_move_scroll<T: 'static>(
4542 this: &mut ProjectPanel,
4543 e: &DragMoveEvent<T>,
4544 window: &mut Window,
4545 cx: &mut Context<ProjectPanel>,
4546 ) {
4547 if !e.bounds.contains(&e.event.position) {
4548 return;
4549 }
4550 this.hover_scroll_task.take();
4551 let panel_height = e.bounds.size.height;
4552 if panel_height <= px(0.) {
4553 return;
4554 }
4555
4556 let event_offset = e.event.position.y - e.bounds.origin.y;
4557 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4558 let hovered_region_offset = event_offset / panel_height;
4559
4560 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4561 // These pixels offsets were picked arbitrarily.
4562 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4563 8.
4564 } else if hovered_region_offset <= 0.15 {
4565 5.
4566 } else if hovered_region_offset >= 0.95 {
4567 -8.
4568 } else if hovered_region_offset >= 0.85 {
4569 -5.
4570 } else {
4571 return;
4572 };
4573 let adjustment = point(px(0.), px(vertical_scroll_offset));
4574 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
4575 loop {
4576 let should_stop_scrolling = this
4577 .update(cx, |this, cx| {
4578 this.hover_scroll_task.as_ref()?;
4579 let handle = this.scroll_handle.0.borrow_mut();
4580 let offset = handle.base_handle.offset();
4581
4582 handle.base_handle.set_offset(offset + adjustment);
4583 cx.notify();
4584 Some(())
4585 })
4586 .ok()
4587 .flatten()
4588 .is_some();
4589 if should_stop_scrolling {
4590 return;
4591 }
4592 cx.background_executor()
4593 .timer(Duration::from_millis(16))
4594 .await;
4595 }
4596 }));
4597 }
4598 h_flex()
4599 .id("project-panel")
4600 .group("project-panel")
4601 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4602 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4603 .size_full()
4604 .relative()
4605 .on_hover(cx.listener(|this, hovered, window, cx| {
4606 if *hovered {
4607 this.show_scrollbar = true;
4608 this.hide_scrollbar_task.take();
4609 cx.notify();
4610 } else if !this.focus_handle.contains_focused(window, cx) {
4611 this.hide_scrollbar(window, cx);
4612 }
4613 }))
4614 .on_click(cx.listener(|this, _event, _, cx| {
4615 cx.stop_propagation();
4616 this.selection = None;
4617 this.marked_entries.clear();
4618 }))
4619 .key_context(self.dispatch_context(window, cx))
4620 .on_action(cx.listener(Self::select_next))
4621 .on_action(cx.listener(Self::select_previous))
4622 .on_action(cx.listener(Self::select_first))
4623 .on_action(cx.listener(Self::select_last))
4624 .on_action(cx.listener(Self::select_parent))
4625 .on_action(cx.listener(Self::select_next_git_entry))
4626 .on_action(cx.listener(Self::select_prev_git_entry))
4627 .on_action(cx.listener(Self::select_next_diagnostic))
4628 .on_action(cx.listener(Self::select_prev_diagnostic))
4629 .on_action(cx.listener(Self::select_next_directory))
4630 .on_action(cx.listener(Self::select_prev_directory))
4631 .on_action(cx.listener(Self::expand_selected_entry))
4632 .on_action(cx.listener(Self::collapse_selected_entry))
4633 .on_action(cx.listener(Self::collapse_all_entries))
4634 .on_action(cx.listener(Self::open))
4635 .on_action(cx.listener(Self::open_permanent))
4636 .on_action(cx.listener(Self::confirm))
4637 .on_action(cx.listener(Self::cancel))
4638 .on_action(cx.listener(Self::copy_path))
4639 .on_action(cx.listener(Self::copy_relative_path))
4640 .on_action(cx.listener(Self::new_search_in_directory))
4641 .on_action(cx.listener(Self::unfold_directory))
4642 .on_action(cx.listener(Self::fold_directory))
4643 .on_action(cx.listener(Self::remove_from_project))
4644 .when(!project.is_read_only(cx), |el| {
4645 el.on_action(cx.listener(Self::new_file))
4646 .on_action(cx.listener(Self::new_directory))
4647 .on_action(cx.listener(Self::rename))
4648 .on_action(cx.listener(Self::delete))
4649 .on_action(cx.listener(Self::trash))
4650 .on_action(cx.listener(Self::cut))
4651 .on_action(cx.listener(Self::copy))
4652 .on_action(cx.listener(Self::paste))
4653 .on_action(cx.listener(Self::duplicate))
4654 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4655 if event.up.click_count > 1 {
4656 if let Some(entry_id) = this.last_worktree_root_id {
4657 let project = this.project.read(cx);
4658
4659 let worktree_id = if let Some(worktree) =
4660 project.worktree_for_entry(entry_id, cx)
4661 {
4662 worktree.read(cx).id()
4663 } else {
4664 return;
4665 };
4666
4667 this.selection = Some(SelectedEntry {
4668 worktree_id,
4669 entry_id,
4670 });
4671
4672 this.new_file(&NewFile, window, cx);
4673 }
4674 }
4675 }))
4676 })
4677 .when(project.is_local(), |el| {
4678 el.on_action(cx.listener(Self::reveal_in_finder))
4679 .on_action(cx.listener(Self::open_system))
4680 .on_action(cx.listener(Self::open_in_terminal))
4681 })
4682 .when(project.is_via_ssh(), |el| {
4683 el.on_action(cx.listener(Self::open_in_terminal))
4684 })
4685 .on_mouse_down(
4686 MouseButton::Right,
4687 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4688 // When deploying the context menu anywhere below the last project entry,
4689 // act as if the user clicked the root of the last worktree.
4690 if let Some(entry_id) = this.last_worktree_root_id {
4691 this.deploy_context_menu(event.position, entry_id, window, cx);
4692 }
4693 }),
4694 )
4695 .track_focus(&self.focus_handle(cx))
4696 .child(
4697 uniform_list(cx.entity().clone(), "entries", item_count, {
4698 |this, range, window, cx| {
4699 let mut items = Vec::with_capacity(range.end - range.start);
4700 this.for_each_visible_entry(
4701 range,
4702 window,
4703 cx,
4704 |id, details, window, cx| {
4705 items.push(this.render_entry(id, details, window, cx));
4706 },
4707 );
4708 items
4709 }
4710 })
4711 .when(show_indent_guides, |list| {
4712 list.with_decoration(
4713 ui::indent_guides(
4714 cx.entity().clone(),
4715 px(indent_size),
4716 IndentGuideColors::panel(cx),
4717 |this, range, window, cx| {
4718 let mut items =
4719 SmallVec::with_capacity(range.end - range.start);
4720 this.iter_visible_entries(
4721 range,
4722 window,
4723 cx,
4724 |entry, entries, _, _| {
4725 let (depth, _) = Self::calculate_depth_and_difference(
4726 entry, entries,
4727 );
4728 items.push(depth);
4729 },
4730 );
4731 items
4732 },
4733 )
4734 .on_click(cx.listener(
4735 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4736 if window.modifiers().secondary() {
4737 let ix = active_indent_guide.offset.y;
4738 let Some((target_entry, worktree)) = maybe!({
4739 let (worktree_id, entry) = this.entry_at_index(ix)?;
4740 let worktree = this
4741 .project
4742 .read(cx)
4743 .worktree_for_id(worktree_id, cx)?;
4744 let target_entry = worktree
4745 .read(cx)
4746 .entry_for_path(&entry.path.parent()?)?;
4747 Some((target_entry, worktree))
4748 }) else {
4749 return;
4750 };
4751
4752 this.collapse_entry(target_entry.clone(), worktree, cx);
4753 }
4754 },
4755 ))
4756 .with_render_fn(
4757 cx.entity().clone(),
4758 move |this, params, _, cx| {
4759 const LEFT_OFFSET: Pixels = px(14.);
4760 const PADDING_Y: Pixels = px(4.);
4761 const HITBOX_OVERDRAW: Pixels = px(3.);
4762
4763 let active_indent_guide_index =
4764 this.find_active_indent_guide(¶ms.indent_guides, cx);
4765
4766 let indent_size = params.indent_size;
4767 let item_height = params.item_height;
4768
4769 params
4770 .indent_guides
4771 .into_iter()
4772 .enumerate()
4773 .map(|(idx, layout)| {
4774 let offset = if layout.continues_offscreen {
4775 px(0.)
4776 } else {
4777 PADDING_Y
4778 };
4779 let bounds = Bounds::new(
4780 point(
4781 layout.offset.x * indent_size + LEFT_OFFSET,
4782 layout.offset.y * item_height + offset,
4783 ),
4784 size(
4785 px(1.),
4786 layout.length * item_height - offset * 2.,
4787 ),
4788 );
4789 ui::RenderedIndentGuide {
4790 bounds,
4791 layout,
4792 is_active: Some(idx) == active_indent_guide_index,
4793 hitbox: Some(Bounds::new(
4794 point(
4795 bounds.origin.x - HITBOX_OVERDRAW,
4796 bounds.origin.y,
4797 ),
4798 size(
4799 bounds.size.width + HITBOX_OVERDRAW * 2.,
4800 bounds.size.height,
4801 ),
4802 )),
4803 }
4804 })
4805 .collect()
4806 },
4807 ),
4808 )
4809 })
4810 .size_full()
4811 .with_sizing_behavior(ListSizingBehavior::Infer)
4812 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4813 .with_width_from_item(self.max_width_item_index)
4814 .track_scroll(self.scroll_handle.clone()),
4815 )
4816 .children(self.render_vertical_scrollbar(cx))
4817 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4818 this.pb_4().child(scrollbar)
4819 })
4820 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4821 deferred(
4822 anchored()
4823 .position(*position)
4824 .anchor(gpui::Corner::TopLeft)
4825 .child(menu.clone()),
4826 )
4827 .with_priority(1)
4828 }))
4829 } else {
4830 v_flex()
4831 .id("empty-project_panel")
4832 .size_full()
4833 .p_4()
4834 .track_focus(&self.focus_handle(cx))
4835 .child(
4836 Button::new("open_project", "Open a project")
4837 .full_width()
4838 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
4839 .on_click(cx.listener(|this, _, window, cx| {
4840 this.workspace
4841 .update(cx, |_, cx| {
4842 window.dispatch_action(Box::new(workspace::Open), cx)
4843 })
4844 .log_err();
4845 })),
4846 )
4847 .when(is_local, |div| {
4848 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4849 style.bg(cx.theme().colors().drop_target_background)
4850 })
4851 .on_drop(cx.listener(
4852 move |this, external_paths: &ExternalPaths, window, cx| {
4853 this.last_external_paths_drag_over_entry = None;
4854 this.marked_entries.clear();
4855 this.hover_scroll_task.take();
4856 if let Some(task) = this
4857 .workspace
4858 .update(cx, |workspace, cx| {
4859 workspace.open_workspace_for_paths(
4860 true,
4861 external_paths.paths().to_owned(),
4862 window,
4863 cx,
4864 )
4865 })
4866 .log_err()
4867 {
4868 task.detach_and_log_err(cx);
4869 }
4870 cx.stop_propagation();
4871 },
4872 ))
4873 })
4874 }
4875 }
4876}
4877
4878impl Render for DraggedProjectEntryView {
4879 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4880 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4881 h_flex()
4882 .font(ui_font)
4883 .pl(self.click_offset.x + px(12.))
4884 .pt(self.click_offset.y + px(12.))
4885 .child(
4886 div()
4887 .flex()
4888 .gap_1()
4889 .items_center()
4890 .py_1()
4891 .px_2()
4892 .rounded_lg()
4893 .bg(cx.theme().colors().background)
4894 .map(|this| {
4895 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4896 this.child(Label::new(format!("{} entries", self.selections.len())))
4897 } else {
4898 this.child(if let Some(icon) = &self.details.icon {
4899 div().child(Icon::from_path(icon.clone()))
4900 } else {
4901 div()
4902 })
4903 .child(Label::new(self.details.filename.clone()))
4904 }
4905 }),
4906 )
4907 }
4908}
4909
4910impl EventEmitter<Event> for ProjectPanel {}
4911
4912impl EventEmitter<PanelEvent> for ProjectPanel {}
4913
4914impl Panel for ProjectPanel {
4915 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4916 match ProjectPanelSettings::get_global(cx).dock {
4917 ProjectPanelDockPosition::Left => DockPosition::Left,
4918 ProjectPanelDockPosition::Right => DockPosition::Right,
4919 }
4920 }
4921
4922 fn position_is_valid(&self, position: DockPosition) -> bool {
4923 matches!(position, DockPosition::Left | DockPosition::Right)
4924 }
4925
4926 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4927 settings::update_settings_file::<ProjectPanelSettings>(
4928 self.fs.clone(),
4929 cx,
4930 move |settings, _| {
4931 let dock = match position {
4932 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4933 DockPosition::Right => ProjectPanelDockPosition::Right,
4934 };
4935 settings.dock = Some(dock);
4936 },
4937 );
4938 }
4939
4940 fn size(&self, _: &Window, cx: &App) -> Pixels {
4941 self.width
4942 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4943 }
4944
4945 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4946 self.width = size;
4947 self.serialize(cx);
4948 cx.notify();
4949 }
4950
4951 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4952 ProjectPanelSettings::get_global(cx)
4953 .button
4954 .then_some(IconName::FileTree)
4955 }
4956
4957 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4958 Some("Project Panel")
4959 }
4960
4961 fn toggle_action(&self) -> Box<dyn Action> {
4962 Box::new(ToggleFocus)
4963 }
4964
4965 fn persistent_name() -> &'static str {
4966 "Project Panel"
4967 }
4968
4969 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4970 let project = &self.project.read(cx);
4971 project.visible_worktrees(cx).any(|tree| {
4972 tree.read(cx)
4973 .root_entry()
4974 .map_or(false, |entry| entry.is_dir())
4975 })
4976 }
4977
4978 fn activation_priority(&self) -> u32 {
4979 0
4980 }
4981}
4982
4983impl Focusable for ProjectPanel {
4984 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4985 self.focus_handle.clone()
4986 }
4987}
4988
4989impl ClipboardEntry {
4990 fn is_cut(&self) -> bool {
4991 matches!(self, Self::Cut { .. })
4992 }
4993
4994 fn items(&self) -> &BTreeSet<SelectedEntry> {
4995 match self {
4996 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4997 }
4998 }
4999}
5000
5001#[cfg(test)]
5002mod project_panel_tests;