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
This commit is contained in:
Kirill Bulatov 2024-07-01 19:36:20 +03:00 committed by GitHub
parent 25ad3185e0
commit 0e60730742
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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<EntryOwned>,
active_item: Option<ActiveItem>,
_subscriptions: Vec<Subscription>,
loading_outlines: bool,
update_task: Task<()>,
outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>,
excerpts: HashMap<BufferId, HashMap<ExcerptId, Excerpt>>,
@ -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<Project>, cx: &AppContext) -> Option<PathBuf> {
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<Project>, cx: &AppContext) -> Option<PathBuf> {
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<Project>,
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<Editor>,
@ -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::<Vec<_>>();
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<Self>) {
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,17 +1169,26 @@ 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)?;
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,
_ => return,
@ -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
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))
{
if let Some(excerpts) = self.excerpts.get(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<BufferSnapshot> {
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<PathBuf> {
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<PathBuf> {
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<Self>) -> 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")