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