From 0e607307426518ebec1314153b5264d8a3524c16 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 1 Jul 2024 19:36:20 +0300 Subject: [PATCH] Slightly improve project panel ergonomics (#13704) * properly fetch outlines from channel notes and other project-less external files * show better messages when for no contents * make file entries collapsible (hiding all excerpts and outlines beneath), keep the initial panel state unfolded up to file level Release Notes: - Slightly improved project panel ergonomics --- crates/outline_panel/src/outline_panel.rs | 330 +++++++++++++++------- 1 file changed, 223 insertions(+), 107 deletions(-) diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index df16c61e9b..401605319a 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -40,7 +40,7 @@ use workspace::{ item::ItemHandle, ui::{ h_flex, v_flex, ActiveTheme, Color, ContextMenu, FluentBuilder, Icon, IconName, IconSize, - Label, LabelCommon, ListItem, Selectable, StyledTypography, + Label, LabelCommon, ListItem, Selectable, Spacing, StyledTypography, }, OpenInTerminal, Workspace, }; @@ -85,6 +85,7 @@ pub struct OutlinePanel { selected_entry: Option, active_item: Option, _subscriptions: Vec, + loading_outlines: bool, update_task: Task<()>, outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>, excerpts: HashMap>, @@ -94,6 +95,8 @@ pub struct OutlinePanel { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum CollapsedEntry { Dir(WorktreeId, ProjectEntryId), + File(WorktreeId, BufferId), + ExternalFile(BufferId), Excerpt(BufferId, ExcerptId), } @@ -155,19 +158,6 @@ impl EntryOwned { } } } - - fn abs_path(&self, project: &Model, cx: &AppContext) -> Option { - match self { - Self::Entry(entry) => entry.abs_path(project, cx), - Self::FoldedDirs(worktree_id, dirs) => dirs.last().and_then(|entry| { - project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok()) - }), - Self::Excerpt(..) | Self::Outline(..) => None, - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -218,46 +208,6 @@ impl PartialEq for FsEntry { } } -impl FsEntry { - fn abs_path(&self, project: &Model, cx: &AppContext) -> Option { - match self { - Self::ExternalFile(buffer_id, _) => project - .read(cx) - .buffer_for_id(*buffer_id) - .and_then(|buffer| File::from_dyn(buffer.read(cx).file())) - .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok()), - Self::Directory(worktree_id, entry) => project - .read(cx) - .worktree_for_id(*worktree_id, cx)? - .read(cx) - .absolutize(&entry.path) - .ok(), - Self::File(worktree_id, entry, _, _) => project - .read(cx) - .worktree_for_id(*worktree_id, cx)? - .read(cx) - .absolutize(&entry.path) - .ok(), - } - } - - fn relative_path<'a>( - &'a self, - project: &Model, - cx: &'a AppContext, - ) -> Option<&'a Path> { - match self { - Self::ExternalFile(buffer_id, _) => project - .read(cx) - .buffer_for_id(*buffer_id) - .and_then(|buffer| buffer.read(cx).file()) - .map(|file| file.path().as_ref()), - Self::Directory(_, entry) => Some(entry.path.as_ref()), - Self::File(_, entry, ..) => Some(entry.path.as_ref()), - } - } -} - struct ActiveItem { item_id: EntityId, active_editor: WeakView, @@ -383,6 +333,7 @@ impl OutlinePanel { width: None, active_item: None, pending_serialization: Task::ready(None), + loading_outlines: false, update_task: Task::ready(()), outline_fetch_tasks: HashMap::default(), excerpts: HashMap::default(), @@ -529,7 +480,9 @@ impl OutlinePanel { Point::new(0.0, -(active_editor.read(cx).file_header_size() as f32)) }; + self.toggle_expanded(entry, cx); match entry { + EntryOwned::FoldedDirs(..) | EntryOwned::Entry(FsEntry::Directory(..)) => {} EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _)) => { let scroll_target = multi_buffer_snapshot.excerpts().find_map( |(excerpt_id, buffer_snapshot, excerpt_range)| { @@ -554,12 +507,6 @@ impl OutlinePanel { }) } } - entry @ EntryOwned::Entry(FsEntry::Directory(..)) => { - self.toggle_expanded(entry, cx); - } - entry @ EntryOwned::FoldedDirs(..) => { - self.toggle_expanded(entry, cx); - } EntryOwned::Entry(FsEntry::File(_, file_entry, ..)) => { let scroll_target = self .project @@ -610,8 +557,7 @@ impl OutlinePanel { }) } } - excerpt_entry @ EntryOwned::Excerpt(_, excerpt_id, excerpt_range) => { - self.toggle_expanded(excerpt_entry, cx); + EntryOwned::Excerpt(_, excerpt_id, excerpt_range) => { let scroll_target = multi_buffer_snapshot .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start); if let Some(anchor) = scroll_target { @@ -923,10 +869,16 @@ impl OutlinePanel { Some(EntryOwned::Entry(FsEntry::Directory(worktree_id, dir_entry))) => { Some(CollapsedEntry::Dir(*worktree_id, dir_entry.id)) } + Some(EntryOwned::Entry(FsEntry::File(worktree_id, _, buffer_id, _))) => { + Some(CollapsedEntry::File(*worktree_id, *buffer_id)) + } + Some(EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _))) => { + Some(CollapsedEntry::ExternalFile(*buffer_id)) + } Some(EntryOwned::Excerpt(buffer_id, excerpt_id, _)) => { Some(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)) } - _ => None, + None | Some(EntryOwned::Outline(..)) => None, }; let Some(collapsed_entry) = entry_to_expand else { return; @@ -967,6 +919,30 @@ impl OutlinePanel { cx, ); } + Some(file_entry @ EntryOwned::Entry(FsEntry::File(worktree_id, _, buffer_id, _))) => { + self.collapsed_entries + .insert(CollapsedEntry::File(*worktree_id, *buffer_id)); + self.update_fs_entries( + &editor, + HashSet::default(), + Some(file_entry.clone()), + None, + false, + cx, + ); + } + Some(file_entry @ EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _))) => { + self.collapsed_entries + .insert(CollapsedEntry::ExternalFile(*buffer_id)); + self.update_fs_entries( + &editor, + HashSet::default(), + Some(file_entry.clone()), + None, + false, + cx, + ); + } Some(dirs_entry @ EntryOwned::FoldedDirs(worktree_id, dir_entries)) => { if let Some(dir_entry) = dir_entries.last() { if self @@ -999,7 +975,7 @@ impl OutlinePanel { ); } } - _ => (), + None | Some(EntryOwned::Outline(..)) => {} } } @@ -1019,13 +995,19 @@ impl OutlinePanel { EntryOwned::Entry(FsEntry::Directory(worktree_id, entry)) => { Some(CollapsedEntry::Dir(*worktree_id, entry.id)) } + EntryOwned::Entry(FsEntry::File(worktree_id, _, buffer_id, _)) => { + Some(CollapsedEntry::File(*worktree_id, *buffer_id)) + } + EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _)) => { + Some(CollapsedEntry::ExternalFile(*buffer_id)) + } EntryOwned::FoldedDirs(worktree_id, entries) => { Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id)) } EntryOwned::Excerpt(buffer_id, excerpt_id, _) => { Some(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)) } - _ => None, + EntryOwned::Outline(..) => None, }) .collect::>(); self.collapsed_entries.extend(new_entries); @@ -1056,6 +1038,18 @@ impl OutlinePanel { self.collapsed_entries.insert(collapsed_entry); } } + EntryOwned::Entry(FsEntry::File(worktree_id, _, buffer_id, _)) => { + let collapsed_entry = CollapsedEntry::File(*worktree_id, *buffer_id); + if !self.collapsed_entries.remove(&collapsed_entry) { + self.collapsed_entries.insert(collapsed_entry); + } + } + EntryOwned::Entry(FsEntry::ExternalFile(buffer_id, _)) => { + let collapsed_entry = CollapsedEntry::ExternalFile(*buffer_id); + if !self.collapsed_entries.remove(&collapsed_entry) { + self.collapsed_entries.insert(collapsed_entry); + } + } EntryOwned::FoldedDirs(worktree_id, dir_entries) => { if let Some(entry_id) = dir_entries.first().map(|entry| entry.id) { let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); @@ -1077,7 +1071,7 @@ impl OutlinePanel { self.collapsed_entries.insert(collapsed_entry); } } - _ => return, + EntryOwned::Outline(..) => return, } self.update_fs_entries( @@ -1094,7 +1088,7 @@ impl OutlinePanel { if let Some(clipboard_text) = self .selected_entry .as_ref() - .and_then(|entry| entry.abs_path(&self.project, cx)) + .and_then(|entry| self.abs_path(&entry, cx)) .map(|p| p.to_string_lossy().to_string()) { cx.write_to_clipboard(ClipboardItem::new(clipboard_text)); @@ -1106,8 +1100,10 @@ impl OutlinePanel { .selected_entry .as_ref() .and_then(|entry| match entry { - EntryOwned::Entry(entry) => entry.relative_path(&self.project, cx), - EntryOwned::FoldedDirs(_, dirs) => dirs.last().map(|entry| entry.path.as_ref()), + EntryOwned::Entry(entry) => self.relative_path(&entry, cx), + EntryOwned::FoldedDirs(_, dirs) => { + dirs.last().map(|entry| entry.path.to_path_buf()) + } EntryOwned::Excerpt(..) | EntryOwned::Outline(..) => None, }) .map(|p| p.to_string_lossy().to_string()) @@ -1120,7 +1116,7 @@ impl OutlinePanel { if let Some(abs_path) = self .selected_entry .as_ref() - .and_then(|entry| entry.abs_path(&self.project, cx)) + .and_then(|entry| self.abs_path(&entry, cx)) { cx.reveal_path(&abs_path); } @@ -1128,7 +1124,7 @@ impl OutlinePanel { fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext) { let selected_entry = self.selected_entry.as_ref(); - let abs_path = selected_entry.and_then(|entry| entry.abs_path(&self.project, cx)); + let abs_path = selected_entry.and_then(|entry| self.abs_path(&entry, cx)); let working_directory = if let ( Some(abs_path), Some(EntryOwned::Entry(FsEntry::File(..) | FsEntry::ExternalFile(..))), @@ -1173,16 +1169,25 @@ impl OutlinePanel { } EntryOwned::Outline(buffer_id, excerpt_id, _) | EntryOwned::Excerpt(buffer_id, excerpt_id, _) => { + self.collapsed_entries + .remove(&CollapsedEntry::ExternalFile(buffer_id)); self.collapsed_entries .remove(&CollapsedEntry::Excerpt(buffer_id, excerpt_id)); let project = self.project.read(cx); let entry_id = project .buffer_for_id(buffer_id) .and_then(|buffer| buffer.read(cx).entry_id(cx)); + entry_id.and_then(|entry_id| { - let worktree = project.worktree_for_entry(entry_id, cx)?; - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) + project + .worktree_for_entry(entry_id, cx) + .and_then(|worktree| { + let worktree_id = worktree.read(cx).id(); + self.collapsed_entries + .remove(&CollapsedEntry::File(worktree_id, buffer_id)); + let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); + Some((worktree, entry)) + }) }) } EntryOwned::Entry(FsEntry::ExternalFile(..)) => None, @@ -1274,12 +1279,7 @@ impl OutlinePanel { } .unwrap_or_else(empty_icon); - let buffer_snapshot = self - .project - .read(cx) - .buffer_for_id(buffer_id)? - .read(cx) - .snapshot(); + let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx)?; let excerpt_range = range.context.to_point(&buffer_snapshot); let label_element = Label::new(format!( "Lines {}-{}", @@ -1397,8 +1397,8 @@ impl OutlinePanel { } FsEntry::ExternalFile(buffer_id, ..) => { let color = entry_label_color(is_active); - let (icon, name) = match self.project.read(cx).buffer_for_id(*buffer_id) { - Some(buffer) => match buffer.read(cx).file() { + let (icon, name) = match self.buffer_snapshot_for_id(*buffer_id, cx) { + Some(buffer_snapshot) => match buffer_snapshot.file() { Some(file) => { let path = file.path(); let icon = if settings.file_icons { @@ -1621,10 +1621,12 @@ impl OutlinePanel { let file = File::from_dyn(buffer_snapshot.file()); let entry_id = file.and_then(|file| file.project_entry_id(cx)); let worktree = file.map(|file| file.worktree.read(cx).snapshot()); + let is_new = + new_entries.contains(&excerpt_id) || !self.excerpts.contains_key(&buffer_id); buffer_excerpts .entry(buffer_id) - .or_insert_with(|| (Vec::new(), entry_id, worktree)) - .0 + .or_insert_with(|| (is_new, Vec::new(), entry_id, worktree)) + .1 .push(excerpt_id); let outlines = match self @@ -1656,6 +1658,7 @@ impl OutlinePanel { }, ); + self.loading_outlines = true; self.update_task = cx.spawn(|outline_panel, mut cx| async move { if let Some(debounce) = debounce { cx.background_executor().timer(debounce).await; @@ -1672,7 +1675,25 @@ impl OutlinePanel { >::default(); let mut external_excerpts = HashMap::default(); - for (buffer_id, (excerpts, entry_id, worktree)) in buffer_excerpts { + for (buffer_id, (is_new, excerpts, entry_id, worktree)) in buffer_excerpts { + if is_new { + match &worktree { + Some(worktree) => { + new_collapsed_entries + .insert(CollapsedEntry::File(worktree.id(), buffer_id)); + } + None => { + new_collapsed_entries + .insert(CollapsedEntry::ExternalFile(buffer_id)); + } + } + + for excerpt_id in &excerpts { + new_collapsed_entries + .insert(CollapsedEntry::Excerpt(buffer_id, *excerpt_id)); + } + } + if let Some(worktree) = worktree { let worktree_id = worktree.id(); let unfolded_dirs = @@ -1688,9 +1709,6 @@ impl OutlinePanel { ); let mut entries_to_add = HashSet::default(); - let is_new = excerpts - .iter() - .any(|excerpt_id| new_entries.contains(excerpt_id)); worktree_excerpts .entry(worktree_id) .or_default() @@ -1945,6 +1963,7 @@ impl OutlinePanel { outline_panel .update(&mut cx, |outline_panel, cx| { + outline_panel.loading_outlines = false; outline_panel.excerpts = new_excerpts; outline_panel.collapsed_entries = new_collapsed_entries; outline_panel.unfolded_dirs = new_unfolded_dirs; @@ -2266,10 +2285,34 @@ impl OutlinePanel { } entries.push((depth, EntryOwned::Entry(entry.clone()))); - if let FsEntry::File(_, _, buffer_id, entry_excerpts) - | FsEntry::ExternalFile(buffer_id, entry_excerpts) = entry - { - if let Some(excerpts) = self.excerpts.get(buffer_id) { + + let excerpts_to_consider = match entry { + FsEntry::File(worktree_id, _, buffer_id, entry_excerpts) => { + if is_singleton + || !self + .collapsed_entries + .contains(&CollapsedEntry::File(*worktree_id, *buffer_id)) + { + Some((*buffer_id, entry_excerpts)) + } else { + None + } + } + FsEntry::ExternalFile(buffer_id, entry_excerpts) => { + if is_singleton + || !self + .collapsed_entries + .contains(&CollapsedEntry::ExternalFile(*buffer_id)) + { + Some((*buffer_id, entry_excerpts)) + } else { + None + } + } + _ => None, + }; + if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider { + if let Some(excerpts) = self.excerpts.get(&buffer_id) { for &entry_excerpt in entry_excerpts { let Some(excerpt) = excerpts.get(&entry_excerpt) else { continue; @@ -2278,7 +2321,7 @@ impl OutlinePanel { entries.push(( excerpt_depth, EntryOwned::Excerpt( - *buffer_id, + buffer_id, entry_excerpt, excerpt.range.clone(), ), @@ -2290,7 +2333,7 @@ impl OutlinePanel { entries.clear(); } else if self .collapsed_entries - .contains(&CollapsedEntry::Excerpt(*buffer_id, entry_excerpt)) + .contains(&CollapsedEntry::Excerpt(buffer_id, entry_excerpt)) { continue; } @@ -2298,7 +2341,7 @@ impl OutlinePanel { for outline in excerpt.iter_outlines() { entries.push(( outline_base_depth + outline.depth, - EntryOwned::Outline(*buffer_id, entry_excerpt, outline.clone()), + EntryOwned::Outline(buffer_id, entry_excerpt, outline.clone()), )); } if is_singleton && entries.is_empty() { @@ -2306,7 +2349,7 @@ impl OutlinePanel { } } } - }; + } } if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() { entries.push(( @@ -2384,11 +2427,8 @@ impl OutlinePanel { .insert(file_excerpt, excerpt.range.clone()); } hash_map::Entry::Vacant(v) => { - if let Some(buffer_snapshot) = self - .project - .read(cx) - .buffer_for_id(*buffer_id) - .map(|buffer| buffer.read(cx).snapshot()) + if let Some(buffer_snapshot) = + self.buffer_snapshot_for_id(*buffer_id, cx) { v.insert((buffer_snapshot, HashMap::default())) .1 @@ -2411,11 +2451,8 @@ impl OutlinePanel { o.get_mut().1.insert(*excerpt_id, excerpt.range.clone()); } hash_map::Entry::Vacant(v) => { - if let Some(buffer_snapshot) = self - .project - .read(cx) - .buffer_for_id(*buffer_id) - .map(|buffer| buffer.read(cx).snapshot()) + if let Some(buffer_snapshot) = + self.buffer_snapshot_for_id(*buffer_id, cx) { v.insert((buffer_snapshot, HashMap::default())) .1 @@ -2433,6 +2470,60 @@ impl OutlinePanel { None => HashMap::default(), } } + + fn buffer_snapshot_for_id( + &self, + buffer_id: BufferId, + cx: &AppContext, + ) -> Option { + let editor = self.active_item.as_ref()?.active_editor.upgrade()?; + Some( + editor + .read(cx) + .buffer() + .read(cx) + .buffer(buffer_id)? + .read(cx) + .snapshot(), + ) + } + + fn abs_path(&self, entry: &EntryOwned, cx: &AppContext) -> Option { + match entry { + EntryOwned::Entry( + FsEntry::File(_, _, buffer_id, _) | FsEntry::ExternalFile(buffer_id, _), + ) => self + .buffer_snapshot_for_id(*buffer_id, cx) + .and_then(|buffer_snapshot| { + let file = File::from_dyn(buffer_snapshot.file())?; + file.worktree.read(cx).absolutize(&file.path).ok() + }), + EntryOwned::Entry(FsEntry::Directory(worktree_id, entry)) => self + .project + .read(cx) + .worktree_for_id(*worktree_id, cx)? + .read(cx) + .absolutize(&entry.path) + .ok(), + EntryOwned::FoldedDirs(worktree_id, dirs) => dirs.last().and_then(|entry| { + self.project + .read(cx) + .worktree_for_id(*worktree_id, cx) + .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok()) + }), + EntryOwned::Excerpt(..) | EntryOwned::Outline(..) => None, + } + } + + fn relative_path(&self, entry: &FsEntry, cx: &AppContext) -> Option { + match entry { + FsEntry::ExternalFile(buffer_id, _) => self + .buffer_snapshot_for_id(*buffer_id, cx) + .and_then(|buffer_snapshot| Some(buffer_snapshot.file()?.path().to_path_buf())), + FsEntry::Directory(_, entry) => Some(entry.path.to_path_buf()), + FsEntry::File(_, entry, ..) => Some(entry.path.to_path_buf()), + } + } } fn back_to_common_visited_parent( @@ -2572,12 +2663,37 @@ impl Render for OutlinePanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let project = self.project.read(cx); if self.fs_entries.is_empty() { + let header = if self.loading_outlines { + "Loading outlines" + } else { + "No outlines available" + }; v_flex() .id("empty-outline_panel") + .justify_center() .size_full() .p_4() .track_focus(&self.focus_handle) - .child(Label::new("No editor outlines available")) + .child(h_flex().justify_center().child(Label::new(header))) + .child( + h_flex() + .pt(Spacing::Small.rems(cx)) + .justify_center() + .child({ + let keystroke = match self.position(cx) { + DockPosition::Left => { + cx.keystroke_text_for(&workspace::ToggleLeftDock) + } + DockPosition::Bottom => { + cx.keystroke_text_for(&workspace::ToggleBottomDock) + } + DockPosition::Right => { + cx.keystroke_text_for(&workspace::ToggleRightDock) + } + }; + Label::new(format!("Toggle this panel with {keystroke}",)) + }), + ) } else { h_flex() .id("outline-panel")