Slightly improve project panel ergonomics (#13704)

Kirill Bulatov created

* 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

Change summary

crates/outline_panel/src/outline_panel.rs | 330 ++++++++++++++++--------
1 file changed, 223 insertions(+), 107 deletions(-)

Detailed changes

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<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,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<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")