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