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