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