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