{
+ let settings = OutlinePanelSettings::get_global(cx);
+ let rendered_entry = rendered_entry.to_owned_entry();
+ div()
+ .id(item_id.clone())
+ .child(
+ ListItem::new(item_id)
+ .indent_level(depth)
+ .indent_step_size(px(settings.indent_size))
+ .selected(is_active)
+ .child(if let Some(icon) = icon {
+ h_flex().child(icon)
+ } else {
+ h_flex()
+ .size(IconSize::default().rems())
+ .invisible()
+ .flex_none()
+ })
+ .child(h_flex().h_6().child(label_element).ml_1())
+ .on_click({
+ let clicked_entry = rendered_entry.clone();
+ cx.listener(move |outline_panel, event: &gpui::ClickEvent, cx| {
+ if event.down.button == MouseButton::Right || event.down.first_mouse {
+ return;
+ }
+
+ let Some(active_editor) = outline_panel
+ .active_item
+ .as_ref()
+ .and_then(|item| item.active_editor.upgrade())
+ else {
+ return;
+ };
+ let active_multi_buffer = active_editor.read(cx).buffer().clone();
+ let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
+
+ match &clicked_entry {
+ EntryOwned::Entry(FsEntry::ExternalFile(buffer_id)) => {
+ let scroll_target = multi_buffer_snapshot.excerpts().find_map(
+ |(excerpt_id, buffer_snapshot, excerpt_range)| {
+ if &buffer_snapshot.remote_id() == buffer_id {
+ multi_buffer_snapshot.anchor_in_excerpt(
+ excerpt_id,
+ excerpt_range.context.start,
+ )
+ } else {
+ None
+ }
+ },
+ );
+ if let Some(anchor) = scroll_target {
+ outline_panel.selected_entry = Some(clicked_entry.clone());
+ active_editor.update(cx, |editor, cx| {
+ editor.set_scroll_anchor(
+ ScrollAnchor {
+ offset: Point::new(
+ 0.0,
+ -(editor.file_header_size() as f32),
+ ),
+ anchor,
+ },
+ cx,
+ );
+ })
+ }
+ }
+ entry @ EntryOwned::Entry(FsEntry::Directory(..)) => {
+ outline_panel.toggle_expanded(entry, cx);
+ }
+ entry @ EntryOwned::FoldedDirs(..) => {
+ outline_panel.toggle_expanded(entry, cx);
+ }
+ EntryOwned::Entry(FsEntry::File(_, file_entry)) => {
+ let scroll_target = outline_panel
+ .project
+ .update(cx, |project, cx| {
+ project
+ .path_for_entry(file_entry.id, cx)
+ .and_then(|path| project.get_open_buffer(&path, cx))
+ })
+ .map(|buffer| {
+ active_multi_buffer
+ .read(cx)
+ .excerpts_for_buffer(&buffer, cx)
+ })
+ .and_then(|excerpts| {
+ let (excerpt_id, excerpt_range) = excerpts.first()?;
+ multi_buffer_snapshot.anchor_in_excerpt(
+ *excerpt_id,
+ excerpt_range.context.start,
+ )
+ });
+ if let Some(anchor) = scroll_target {
+ outline_panel.selected_entry = Some(clicked_entry.clone());
+ active_editor.update(cx, |editor, cx| {
+ editor.set_scroll_anchor(
+ ScrollAnchor {
+ offset: Point::new(
+ 0.0,
+ -(editor.file_header_size() as f32),
+ ),
+ anchor,
+ },
+ cx,
+ );
+ })
+ }
+ }
+ EntryOwned::Outline(_, outline) => {
+ let Some(full_buffer_snapshot) = outline
+ .range
+ .start
+ .buffer_id
+ .and_then(|buffer_id| {
+ active_multi_buffer.read(cx).buffer(buffer_id)
+ })
+ .or_else(|| {
+ outline.range.end.buffer_id.and_then(|buffer_id| {
+ active_multi_buffer.read(cx).buffer(buffer_id)
+ })
+ })
+ .map(|buffer| buffer.read(cx).snapshot())
+ else {
+ return;
+ };
+ let outline_offset_range =
+ outline.range.to_offset(&full_buffer_snapshot);
+ let scroll_target = multi_buffer_snapshot
+ .excerpts()
+ .filter(|(_, buffer_snapshot, _)| {
+ let buffer_id = buffer_snapshot.remote_id();
+ Some(buffer_id) == outline.range.start.buffer_id
+ || Some(buffer_id) == outline.range.end.buffer_id
+ })
+ .min_by_key(|(_, _, excerpt_range)| {
+ let excerpt_offeset_range = excerpt_range
+ .context
+ .to_offset(&full_buffer_snapshot);
+ ((outline_offset_range.start / 2
+ + outline_offset_range.end / 2)
+ as isize
+ - (excerpt_offeset_range.start / 2
+ + excerpt_offeset_range.end / 2)
+ as isize)
+ .abs()
+ })
+ .and_then(
+ |(excerpt_id, excerpt_snapshot, excerpt_range)| {
+ let location = if outline
+ .range
+ .start
+ .is_valid(excerpt_snapshot)
+ {
+ outline.range.start
+ } else {
+ excerpt_range.context.start
+ };
+ multi_buffer_snapshot
+ .anchor_in_excerpt(excerpt_id, location)
+ },
+ );
+ if let Some(anchor) = scroll_target {
+ outline_panel.selected_entry = Some(clicked_entry.clone());
+ active_editor.update(cx, |editor, cx| {
+ editor.set_scroll_anchor(
+ ScrollAnchor {
+ offset: Point::default(),
+ anchor,
+ },
+ cx,
+ );
+ })
+ }
+ }
+ }
+ })
+ })
+ .on_secondary_mouse_down(cx.listener(
+ move |outline_panel, event: &MouseDownEvent, cx| {
+ // Stop propagation to prevent the catch-all context menu for the project
+ // panel from being deployed.
+ cx.stop_propagation();
+ outline_panel.deploy_context_menu(
+ event.position,
+ rendered_entry.to_ref_entry(),
+ cx,
+ )
+ },
+ )),
+ )
+ .border_1()
+ .border_r_2()
+ .rounded_none()
+ .hover(|style| {
+ if is_active {
+ style
+ } else {
+ let hover_color = cx.theme().colors().ghost_element_hover;
+ style.bg(hover_color).border_color(hover_color)
+ }
+ })
+ .when(is_active && self.focus_handle.contains_focused(cx), |div| {
+ div.border_color(Color::Selected.color(cx))
+ })
+ }
+
+ fn entry_name(
+ &self,
+ worktree_id: &WorktreeId,
+ entry: &Entry,
+ cx: &ViewContext
,
+ ) -> String {
+ let name = match self.project.read(cx).worktree_for_id(*worktree_id, cx) {
+ Some(worktree) => {
+ let worktree = worktree.read(cx);
+ match worktree.snapshot().root_entry() {
+ Some(root_entry) => {
+ if root_entry.id == entry.id {
+ file_name(worktree.abs_path().as_ref())
+ } else {
+ let path = worktree.absolutize(entry.path.as_ref()).ok();
+ let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
+ file_name(path)
+ }
+ }
+ None => {
+ let path = worktree.absolutize(entry.path.as_ref()).ok();
+ let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref());
+ file_name(path)
+ }
+ }
+ }
+ None => file_name(entry.path.as_ref()),
+ };
+ name
+ }
+
+ fn update_fs_entries(
+ &mut self,
+ active_editor: &View,
+ new_entries: HashSet,
+ new_selected_entry: Option,
+ debounce: Option,
+ prefetch: bool,
+ cx: &mut ViewContext,
+ ) {
+ if !self.active {
+ return;
+ }
+
+ let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
+ let active_multi_buffer = active_editor.read(cx).buffer().clone();
+ let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
+ let mut new_collapsed_dirs = self.collapsed_dirs.clone();
+ let mut new_unfolded_dirs = self.unfolded_dirs.clone();
+ let mut root_entries = HashSet::default();
+ let excerpts = multi_buffer_snapshot
+ .excerpts()
+ .map(|(excerpt_id, buffer_snapshot, _)| {
+ 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());
+ (excerpt_id, buffer_snapshot.remote_id(), entry_id, worktree)
+ })
+ .collect::>();
+
+ self.update_task = cx.spawn(|outline_panel, mut cx| async move {
+ if let Some(debounce) = debounce {
+ cx.background_executor().timer(debounce).await;
+ }
+ let Some((new_collapsed_dirs, new_unfolded_dirs, new_fs_entries, new_depth_map)) = cx
+ .background_executor()
+ .spawn(async move {
+ let mut processed_external_buffers = HashSet::default();
+ let mut new_worktree_entries =
+ HashMap::)>::default();
+ let mut external_entries = Vec::default();
+
+ for (excerpt_id, buffer_id, file_entry_id, worktree) in excerpts {
+ let is_new = new_entries.contains(&excerpt_id);
+ if let Some(worktree) = worktree {
+ let collapsed_dirs =
+ new_collapsed_dirs.entry(worktree.id()).or_default();
+ let unfolded_dirs = new_unfolded_dirs.entry(worktree.id()).or_default();
+
+ match file_entry_id
+ .and_then(|id| worktree.entry_for_id(id))
+ .cloned()
+ {
+ Some(entry) => {
+ let mut traversal = worktree.traverse_from_path(
+ true,
+ true,
+ true,
+ entry.path.as_ref(),
+ );
+
+ let mut entries_to_add = HashSet::default();
+ let mut current_entry = entry;
+ loop {
+ if current_entry.is_dir() {
+ let is_root =
+ worktree.root_entry().map(|entry| entry.id)
+ == Some(current_entry.id);
+ if is_root {
+ root_entries.insert(current_entry.id);
+ if auto_fold_dirs {
+ unfolded_dirs.insert(current_entry.id);
+ }
+ }
+
+ if is_new {
+ collapsed_dirs.remove(¤t_entry.id);
+ } else if collapsed_dirs.contains(¤t_entry.id) {
+ entries_to_add.clear();
+ }
+ }
+
+ let new_entry_added = entries_to_add.insert(current_entry);
+ if new_entry_added && traversal.back_to_parent() {
+ if let Some(parent_entry) = traversal.entry() {
+ current_entry = parent_entry.clone();
+ continue;
+ }
+ }
+ break;
+ }
+ new_worktree_entries
+ .entry(worktree.id())
+ .or_insert_with(|| (worktree.clone(), HashSet::default()))
+ .1
+ .extend(entries_to_add);
+ }
+ None => {
+ if processed_external_buffers.insert(buffer_id) {
+ external_entries.push(FsEntry::ExternalFile(buffer_id));
+ }
+ }
+ }
+ } else if processed_external_buffers.insert(buffer_id) {
+ external_entries.push(FsEntry::ExternalFile(buffer_id));
+ }
+ }
+
+ external_entries.sort_by(|entry_a, entry_b| match (entry_a, entry_b) {
+ (
+ FsEntry::ExternalFile(buffer_id_a),
+ FsEntry::ExternalFile(buffer_id_b),
+ ) => buffer_id_a.cmp(&buffer_id_b),
+ (FsEntry::ExternalFile(..), _) => cmp::Ordering::Less,
+ (_, FsEntry::ExternalFile(..)) => cmp::Ordering::Greater,
+ _ => cmp::Ordering::Equal,
+ });
+
+ #[derive(Clone, Copy, Default)]
+ struct Children {
+ files: usize,
+ dirs: usize,
+ }
+ let mut children_count =
+ HashMap::>::default();
+
+ let worktree_entries = new_worktree_entries
+ .into_iter()
+ .map(|(worktree_id, (worktree_snapshot, entries))| {
+ let mut entries = entries.into_iter().collect::>();
+ sort_worktree_entries(&mut entries);
+ worktree_snapshot.propagate_git_statuses(&mut entries);
+ (worktree_id, entries)
+ })
+ .flat_map(|(worktree_id, entries)| {
+ {
+ entries
+ .into_iter()
+ .map(|entry| {
+ if auto_fold_dirs {
+ if let Some(parent) = entry.path.parent() {
+ let children = children_count
+ .entry(worktree_id)
+ .or_default()
+ .entry(parent.to_path_buf())
+ .or_default();
+ if entry.is_dir() {
+ children.dirs += 1;
+ } else {
+ children.files += 1;
+ }
+ }
+ }
+
+ if entry.is_dir() {
+ FsEntry::Directory(worktree_id, entry)
+ } else {
+ FsEntry::File(worktree_id, entry)
+ }
+ })
+ .collect::>()
+ }
+ })
+ .collect::>();
+
+ let mut visited_dirs = Vec::new();
+ let mut new_depth_map = HashMap::default();
+ let new_visible_entries = external_entries
+ .into_iter()
+ .chain(worktree_entries)
+ .filter(|visible_item| {
+ match visible_item {
+ FsEntry::Directory(worktree_id, dir_entry) => {
+ let parent_id = back_to_common_visited_parent(
+ &mut visited_dirs,
+ worktree_id,
+ dir_entry,
+ );
+
+ visited_dirs.push((dir_entry.id, dir_entry.path.clone()));
+ let depth = if root_entries.contains(&dir_entry.id) {
+ 0
+ } else if auto_fold_dirs {
+ let (parent_folded, parent_depth) = match parent_id {
+ Some((worktree_id, id)) => (
+ new_unfolded_dirs
+ .get(&worktree_id)
+ .map_or(true, |unfolded_dirs| {
+ !unfolded_dirs.contains(&id)
+ }),
+ new_depth_map
+ .get(&(worktree_id, id))
+ .map(|&(_, depth)| depth)
+ .unwrap_or(0),
+ ),
+
+ None => (false, 0),
+ };
+
+ let children = children_count
+ .get(&worktree_id)
+ .and_then(|children_count| {
+ children_count.get(&dir_entry.path.to_path_buf())
+ })
+ .copied()
+ .unwrap_or_default();
+ let folded = if children.dirs > 1
+ || (children.dirs == 1 && children.files > 0)
+ || (children.dirs == 0
+ && visited_dirs
+ .last()
+ .map(|(parent_dir_id, _)| {
+ root_entries.contains(parent_dir_id)
+ })
+ .unwrap_or(true))
+ {
+ new_unfolded_dirs
+ .entry(*worktree_id)
+ .or_default()
+ .insert(dir_entry.id);
+ false
+ } else {
+ new_unfolded_dirs.get(&worktree_id).map_or(
+ true,
+ |unfolded_dirs| {
+ !unfolded_dirs.contains(&dir_entry.id)
+ },
+ )
+ };
+
+ if parent_folded && folded {
+ parent_depth
+ } else {
+ parent_depth + 1
+ }
+ } else {
+ parent_id
+ .and_then(|(worktree_id, id)| {
+ new_depth_map
+ .get(&(worktree_id, id))
+ .map(|&(_, depth)| depth)
+ })
+ .unwrap_or(0)
+ + 1
+ };
+ new_depth_map
+ .insert((*worktree_id, dir_entry.id), (true, depth));
+ }
+ FsEntry::File(worktree_id, file_entry) => {
+ let parent_id = back_to_common_visited_parent(
+ &mut visited_dirs,
+ worktree_id,
+ file_entry,
+ );
+ let depth = if root_entries.contains(&file_entry.id) {
+ 0
+ } else {
+ parent_id
+ .and_then(|(worktree_id, id)| {
+ new_depth_map
+ .get(&(worktree_id, id))
+ .map(|&(_, depth)| depth)
+ })
+ .unwrap_or(0)
+ + 1
+ };
+ new_depth_map
+ .insert((*worktree_id, file_entry.id), (false, depth));
+ }
+ FsEntry::ExternalFile(..) => {
+ visited_dirs.clear();
+ }
+ }
+
+ true
+ })
+ .collect::>();
+
+ anyhow::Ok((
+ new_collapsed_dirs,
+ new_unfolded_dirs,
+ new_visible_entries,
+ new_depth_map,
+ ))
+ })
+ .await
+ .log_err()
+ else {
+ return;
+ };
+
+ outline_panel
+ .update(&mut cx, |outline_panel, cx| {
+ outline_panel.collapsed_dirs = new_collapsed_dirs;
+ outline_panel.unfolded_dirs = new_unfolded_dirs;
+ outline_panel.fs_entries = new_fs_entries;
+ outline_panel.fs_entries_depth = new_depth_map;
+ outline_panel.cached_entries_with_depth = None;
+ if new_selected_entry.is_some() {
+ outline_panel.selected_entry = new_selected_entry;
+ }
+ if prefetch {
+ let range = if outline_panel.last_visible_range.is_empty() {
+ 0..(outline_panel.entries_with_depths(cx).len() / 4).min(50)
+ } else {
+ outline_panel.last_visible_range.clone()
+ };
+ outline_panel.fetch_outlines(&range, cx);
+ }
+
+ outline_panel.autoscroll(cx);
+ cx.notify();
+ })
+ .ok();
+ });
+ }
+
+ fn replace_visible_entries(
+ &mut self,
+ new_active_editor: View,
+ cx: &mut ViewContext,
+ ) {
+ self.clear_previous();
+ self.active_item = Some(ActiveItem {
+ item_id: new_active_editor.item_id(),
+ _editor_subscrpiption: subscribe_for_editor_events(&new_active_editor, cx),
+ active_editor: new_active_editor.downgrade(),
+ });
+ let new_entries =
+ HashSet::from_iter(new_active_editor.read(cx).buffer().read(cx).excerpt_ids());
+ self.update_fs_entries(&new_active_editor, new_entries, None, None, true, cx);
+ }
+
+ fn clear_previous(&mut self) {
+ self.collapsed_dirs.clear();
+ self.unfolded_dirs.clear();
+ self.last_visible_range = 0..0;
+ self.selected_entry = None;
+ self.update_task = Task::ready(());
+ self.active_item = None;
+ self.fs_entries.clear();
+ self.fs_entries_depth.clear();
+ self.outline_fetch_tasks.clear();
+ self.outlines.clear();
+ self.cached_entries_with_depth = None;
+ }
+
+ fn location_for_editor_selection(
+ &self,
+ editor: &View,
+ cx: &mut ViewContext,
+ ) -> Option<(OutlinesContainer, Option)> {
+ let selection = editor
+ .read(cx)
+ .selections
+ .newest::(cx)
+ .head();
+ let multi_buffer = editor.read(cx).buffer();
+ let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+ let selection = multi_buffer_snapshot.anchor_before(selection);
+ let buffer_snapshot = multi_buffer_snapshot.buffer_for_excerpt(selection.excerpt_id)?;
+
+ let container = match File::from_dyn(buffer_snapshot.file())
+ .and_then(|file| Some(file.worktree.read(cx).id()).zip(file.entry_id))
+ {
+ Some((worktree_id, id)) => OutlinesContainer::File(worktree_id, id),
+ None => OutlinesContainer::ExternalFile(buffer_snapshot.remote_id()),
+ };
+
+ let outline_item = self
+ .outlines
+ .get(&container)
+ .into_iter()
+ .flatten()
+ .filter(|outline| {
+ outline.range.start.buffer_id == selection.buffer_id
+ || outline.range.end.buffer_id == selection.buffer_id
+ })
+ .filter(|outline_item| {
+ range_contains(&outline_item.range, selection.text_anchor, buffer_snapshot)
+ })
+ .min_by_key(|outline| {
+ let range = outline.range.start.offset..outline.range.end.offset;
+ let cursor_offset = selection.text_anchor.offset as isize;
+ let distance_to_closest_endpoint = cmp::min(
+ (range.start as isize - cursor_offset).abs(),
+ (range.end as isize - cursor_offset).abs(),
+ );
+ distance_to_closest_endpoint
+ })
+ .cloned();
+
+ Some((container, outline_item))
+ }
+
+ fn fetch_outlines(&mut self, range: &Range, cx: &mut ViewContext) {
+ let Some(editor) = self
+ .active_item
+ .as_ref()
+ .and_then(|item| item.active_editor.upgrade())
+ else {
+ return;
+ };
+
+ let range_len = range.len();
+ let half_range = range_len / 2;
+ let entries = self.entries_with_depths(cx);
+ let expanded_range =
+ range.start.saturating_sub(half_range)..(range.end + half_range).min(entries.len());
+ let containers = entries
+ .get(expanded_range)
+ .into_iter()
+ .flatten()
+ .flat_map(|(_, entry)| entry.outlines_container())
+ .collect::>();
+ let fetch_outlines_for = containers
+ .into_iter()
+ .filter(|container| match self.outlines.entry(*container) {
+ hash_map::Entry::Occupied(_) => false,
+ hash_map::Entry::Vacant(v) => {
+ v.insert(Vec::new());
+ true
+ }
+ })
+ .collect::>();
+
+ let outlines_to_fetch = editor
+ .read(cx)
+ .buffer()
+ .read(cx)
+ .snapshot(cx)
+ .excerpts()
+ .filter_map(|(_, buffer_snapshot, excerpt_range)| {
+ let container = match File::from_dyn(buffer_snapshot.file()) {
+ Some(file) => {
+ let entry_id = file.project_entry_id(cx);
+ let worktree_id = file.worktree.read(cx).id();
+ entry_id.map(|entry_id| OutlinesContainer::File(worktree_id, entry_id))
+ }
+ None => Some(OutlinesContainer::ExternalFile(buffer_snapshot.remote_id())),
+ }?;
+ Some((container, (buffer_snapshot.clone(), excerpt_range)))
+ })
+ .filter(|(container, _)| fetch_outlines_for.contains(container))
+ .collect::>();
+ if outlines_to_fetch.is_empty() {
+ return;
+ }
+
+ let syntax_theme = cx.theme().syntax().clone();
+ self.outline_fetch_tasks
+ .push(cx.spawn(|outline_panel, mut cx| async move {
+ let mut processed_outlines =
+ HashMap::>::default();
+ let fetched_outlines = cx
+ .background_executor()
+ .spawn(async move {
+ outlines_to_fetch
+ .into_iter()
+ .map(|(container, (buffer_snapshot, excerpt_range))| {
+ (
+ container,
+ buffer_snapshot
+ .outline_items_containing(
+ excerpt_range.context,
+ false,
+ Some(&syntax_theme),
+ )
+ .unwrap_or_default(),
+ )
+ })
+ .fold(
+ HashMap::default(),
+ |mut outlines, (container, new_outlines)| {
+ outlines
+ .entry(container)
+ .or_insert_with(Vec::new)
+ .extend(new_outlines);
+ outlines
+ },
+ )
+ })
+ .await;
+ outline_panel
+ .update(&mut cx, |outline_panel, cx| {
+ for (container, fetched_outlines) in fetched_outlines {
+ let existing_outlines =
+ outline_panel.outlines.entry(container).or_default();
+ let processed_outlines =
+ processed_outlines.entry(container).or_default();
+ processed_outlines.extend(existing_outlines.iter().cloned());
+ for fetched_outline in fetched_outlines {
+ if processed_outlines.insert(fetched_outline.clone()) {
+ existing_outlines.push(fetched_outline);
+ }
+ }
+ }
+ outline_panel.cached_entries_with_depth = None;
+ cx.notify();
+ })
+ .ok();
+ }));
+ }
+
+ fn entries_with_depths(&mut self, cx: &AppContext) -> &[(usize, EntryOwned)] {
+ self.cached_entries_with_depth.get_or_insert_with(|| {
+ let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
+ let mut folded_dirs_entry = None::<(usize, WorktreeId, Vec)>;
+ let mut entries = Vec::new();
+
+ for entry in &self.fs_entries {
+ let mut depth = match entry {
+ FsEntry::Directory(worktree_id, dir_entry) => {
+ let depth = self
+ .fs_entries_depth
+ .get(&(*worktree_id, dir_entry.id))
+ .map(|&(_, depth)| depth)
+ .unwrap_or(0);
+ if auto_fold_dirs {
+ let folded = self
+ .unfolded_dirs
+ .get(worktree_id)
+ .map_or(true, |unfolded_dirs| {
+ !unfolded_dirs.contains(&dir_entry.id)
+ });
+ if folded {
+ if let Some((folded_depth, folded_worktree_id, mut folded_dirs)) =
+ folded_dirs_entry.take()
+ {
+ if worktree_id == &folded_worktree_id
+ && dir_entry.path.parent()
+ == folded_dirs.last().map(|entry| entry.path.as_ref())
+ {
+ folded_dirs.push(dir_entry.clone());
+ folded_dirs_entry =
+ Some((folded_depth, folded_worktree_id, folded_dirs))
+ } else {
+ entries.push((
+ folded_depth,
+ EntryOwned::FoldedDirs(folded_worktree_id, folded_dirs),
+ ));
+ folded_dirs_entry =
+ Some((depth, *worktree_id, vec![dir_entry.clone()]))
+ }
+ } else {
+ folded_dirs_entry =
+ Some((depth, *worktree_id, vec![dir_entry.clone()]))
+ }
+
+ continue;
+ }
+ }
+ depth
+ }
+ FsEntry::ExternalFile(_) => 0,
+ FsEntry::File(worktree_id, file_entry) => self
+ .fs_entries_depth
+ .get(&(*worktree_id, file_entry.id))
+ .map(|&(_, depth)| depth)
+ .unwrap_or(0),
+ };
+ if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() {
+ entries.push((
+ folded_depth,
+ EntryOwned::FoldedDirs(worktree_id, folded_dirs),
+ ));
+ }
+
+ entries.push((depth, EntryOwned::Entry(entry.clone())));
+ let mut outline_depth = None::;
+ entries.extend(
+ entry
+ .outlines_container()
+ .and_then(|container| Some((container, self.outlines.get(&container)?)))
+ .into_iter()
+ .flat_map(|(container, outlines)| {
+ outlines.iter().map(move |outline| (container, outline))
+ })
+ .map(move |(container, outline)| {
+ if let Some(outline_depth) = outline_depth {
+ match outline_depth.cmp(&outline.depth) {
+ cmp::Ordering::Less => depth += 1,
+ cmp::Ordering::Equal => {}
+ cmp::Ordering::Greater => depth -= 1,
+ };
+ }
+ outline_depth = Some(outline.depth);
+ (depth, EntryOwned::Outline(container, outline.clone()))
+ }),
+ )
+ }
+ if let Some((folded_depth, worktree_id, folded_dirs)) = folded_dirs_entry.take() {
+ entries.push((
+ folded_depth,
+ EntryOwned::FoldedDirs(worktree_id, folded_dirs),
+ ));
+ }
+ entries
+ })
+ }
+}
+
+fn back_to_common_visited_parent(
+ visited_dirs: &mut Vec<(ProjectEntryId, Arc)>,
+ worktree_id: &WorktreeId,
+ new_entry: &Entry,
+) -> Option<(WorktreeId, ProjectEntryId)> {
+ while let Some((visited_dir_id, visited_path)) = visited_dirs.last() {
+ match new_entry.path.parent() {
+ Some(parent_path) => {
+ if parent_path == visited_path.as_ref() {
+ return Some((*worktree_id, *visited_dir_id));
+ }
+ }
+ None => {
+ break;
+ }
+ }
+ visited_dirs.pop();
+ }
+ None
+}
+
+fn sort_worktree_entries(entries: &mut Vec) {
+ entries.sort_by(|entry_a, entry_b| {
+ let mut components_a = entry_a.path.components().peekable();
+ let mut components_b = entry_b.path.components().peekable();
+ loop {
+ match (components_a.next(), components_b.next()) {
+ (Some(component_a), Some(component_b)) => {
+ let a_is_file = components_a.peek().is_none() && entry_a.is_file();
+ let b_is_file = components_b.peek().is_none() && entry_b.is_file();
+ let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
+ let maybe_numeric_ordering = maybe!({
+ let num_and_remainder_a = Path::new(component_a.as_os_str())
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
+ let num_and_remainder_b = Path::new(component_b.as_os_str())
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
+
+ num_and_remainder_a.partial_cmp(&num_and_remainder_b)
+ });
+
+ maybe_numeric_ordering.unwrap_or_else(|| {
+ let name_a = UniCase::new(component_a.as_os_str().to_string_lossy());
+ let name_b = UniCase::new(component_b.as_os_str().to_string_lossy());
+
+ name_a.cmp(&name_b)
+ })
+ });
+ if !ordering.is_eq() {
+ return ordering;
+ }
+ }
+ (Some(_), None) => break cmp::Ordering::Greater,
+ (None, Some(_)) => break cmp::Ordering::Less,
+ (None, None) => break cmp::Ordering::Equal,
+ }
+ }
+ });
+}
+
+fn file_name(path: &Path) -> String {
+ let mut current_path = path;
+ loop {
+ if let Some(file_name) = current_path.file_name() {
+ return file_name.to_string_lossy().into_owned();
+ }
+ match current_path.parent() {
+ Some(parent) => current_path = parent,
+ None => return path.to_string_lossy().into_owned(),
+ }
+ }
+}
+
+fn directory_contains(directory_entry: &Entry, child_entry: &Entry) -> bool {
+ debug_assert!(directory_entry.is_dir());
+ let Some(relative_path) = child_entry.path.strip_prefix(&directory_entry.path).ok() else {
+ return false;
+ };
+ relative_path.iter().count() == 1
+}
+
+impl Panel for OutlinePanel {
+ fn persistent_name() -> &'static str {
+ "Outline Panel"
+ }
+
+ fn position(&self, cx: &WindowContext) -> DockPosition {
+ match OutlinePanelSettings::get_global(cx).dock {
+ OutlinePanelDockPosition::Left => DockPosition::Left,
+ OutlinePanelDockPosition::Right => DockPosition::Right,
+ }
+ }
+
+ fn position_is_valid(&self, position: DockPosition) -> bool {
+ matches!(position, DockPosition::Left | DockPosition::Right)
+ }
+
+ fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) {
+ settings::update_settings_file::(
+ self.fs.clone(),
+ cx,
+ move |settings| {
+ let dock = match position {
+ DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left,
+ DockPosition::Right => OutlinePanelDockPosition::Right,
+ };
+ settings.dock = Some(dock);
+ },
+ );
+ }
+
+ fn size(&self, cx: &WindowContext) -> Pixels {
+ self.width
+ .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width)
+ }
+
+ fn set_size(&mut self, size: Option, cx: &mut ViewContext) {
+ self.width = size;
+ self.serialize(cx);
+ cx.notify();
+ }
+
+ fn icon(&self, cx: &WindowContext) -> Option {
+ OutlinePanelSettings::get_global(cx)
+ .button
+ .then(|| IconName::ListTree)
+ }
+
+ fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> {
+ Some("Outline Panel")
+ }
+
+ fn toggle_action(&self) -> Box {
+ Box::new(ToggleFocus)
+ }
+
+ fn starts_open(&self, _: &WindowContext) -> bool {
+ self.active_item.is_some()
+ }
+
+ fn set_active(&mut self, active: bool, cx: &mut ViewContext) {
+ let old_active = self.active;
+ self.active = active;
+ if active && old_active != active {
+ if let Some(active_editor) = self
+ .active_item
+ .as_ref()
+ .and_then(|item| item.active_editor.upgrade())
+ {
+ self.replace_visible_entries(active_editor, cx);
+ }
+ }
+ }
+}
+
+impl FocusableView for OutlinePanel {
+ fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for OutlinePanel {}
+
+impl EventEmitter for OutlinePanel {}
+
+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() {
+ v_flex()
+ .id("empty-outline_panel")
+ .size_full()
+ .p_4()
+ .track_focus(&self.focus_handle)
+ .child(Label::new("No editor outlines available"))
+ } else {
+ h_flex()
+ .id("outline-panel")
+ .size_full()
+ .relative()
+ .key_context(self.dispatch_context(cx))
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_prev))
+ .on_action(cx.listener(Self::select_first))
+ .on_action(cx.listener(Self::select_last))
+ .on_action(cx.listener(Self::select_parent))
+ .on_action(cx.listener(Self::expand_selected_entry))
+ .on_action(cx.listener(Self::collapse_selected_entry))
+ .on_action(cx.listener(Self::collapse_all_entries))
+ .on_action(cx.listener(Self::copy_path))
+ .on_action(cx.listener(Self::copy_relative_path))
+ .on_action(cx.listener(Self::unfold_directory))
+ .on_action(cx.listener(Self::fold_directory))
+ .when(project.is_local(), |el| {
+ el.on_action(cx.listener(Self::reveal_in_finder))
+ .on_action(cx.listener(Self::open_in_terminal))
+ })
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(move |outline_panel, event: &MouseDownEvent, cx| {
+ if let Some(entry) = outline_panel.selected_entry.clone() {
+ outline_panel.deploy_context_menu(
+ event.position,
+ entry.to_ref_entry(),
+ cx,
+ )
+ } else if let Some(entry) = outline_panel.fs_entries.first().cloned() {
+ outline_panel.deploy_context_menu(
+ event.position,
+ EntryRef::Entry(&entry),
+ cx,
+ )
+ }
+ }),
+ )
+ .track_focus(&self.focus_handle)
+ .child({
+ let items_len = self.entries_with_depths(cx).len();
+ uniform_list(cx.view().clone(), "entries", items_len, {
+ move |outline_panel, range, cx| {
+ outline_panel.last_visible_range = range.clone();
+ outline_panel.fetch_outlines(&range, cx);
+ outline_panel
+ .entries_with_depths(cx)
+ .get(range)
+ .map(|entries| entries.to_vec())
+ .into_iter()
+ .flatten()
+ .map(|(depth, dipslayed_item)| match dipslayed_item {
+ EntryOwned::Entry(entry) => {
+ outline_panel.render_entry(&entry, depth, cx)
+ }
+ EntryOwned::FoldedDirs(worktree_id, entries) => outline_panel
+ .render_folded_dirs(worktree_id, &entries, depth, cx),
+ EntryOwned::Outline(container, outline) => {
+ outline_panel.render_outline(container, &outline, depth, cx)
+ }
+ })
+ .collect()
+ }
+ })
+ .size_full()
+ .track_scroll(self.scroll_handle.clone())
+ })
+ .children(self.context_menu.as_ref().map(|(menu, position, _)| {
+ deferred(
+ anchored()
+ .position(*position)
+ .anchor(gpui::AnchorCorner::TopLeft)
+ .child(menu.clone()),
+ )
+ .with_priority(1)
+ }))
+ }
+ }
+}
+
+fn subscribe_for_editor_events(
+ editor: &View,
+ cx: &mut ViewContext,
+) -> Option {
+ if OutlinePanelSettings::get_global(cx).auto_reveal_entries {
+ let debounce = Some(Duration::from_millis(UPDATE_DEBOUNCE_MILLIS));
+ Some(cx.subscribe(
+ editor,
+ move |outline_panel, editor, e: &EditorEvent, cx| match e {
+ EditorEvent::SelectionsChanged { local: true } => {
+ outline_panel.reveal_entry_for_selection(&editor, cx);
+ cx.notify();
+ }
+ EditorEvent::ExcerptsAdded { excerpts, .. } => {
+ outline_panel.update_fs_entries(
+ &editor,
+ excerpts.iter().map(|&(excerpt_id, _)| excerpt_id).collect(),
+ None,
+ debounce,
+ false,
+ cx,
+ );
+ }
+ EditorEvent::ExcerptsRemoved { .. } => {
+ outline_panel.update_fs_entries(
+ &editor,
+ HashSet::default(),
+ None,
+ debounce,
+ false,
+ cx,
+ );
+ }
+ EditorEvent::ExcerptsExpanded { .. } => {
+ outline_panel.update_fs_entries(
+ &editor,
+ HashSet::default(),
+ None,
+ debounce,
+ true,
+ cx,
+ );
+ }
+ EditorEvent::Reparsed => {
+ outline_panel.outline_fetch_tasks.clear();
+ outline_panel.outlines.clear();
+ outline_panel.update_fs_entries(
+ &editor,
+ HashSet::default(),
+ None,
+ debounce,
+ true,
+ cx,
+ );
+ }
+ _ => {}
+ },
+ ))
+ } else {
+ None
+ }
+}
+
+fn range_contains(
+ range: &Range,
+ anchor: language::Anchor,
+ buffer_snapshot: &language::BufferSnapshot,
+) -> bool {
+ range.start.cmp(&anchor, buffer_snapshot).is_le()
+ && range.end.cmp(&anchor, buffer_snapshot).is_ge()
+}
diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs
new file mode 100644
index 0000000000000..0b5467dd05907
--- /dev/null
+++ b/crates/outline_panel/src/outline_panel_settings.rs
@@ -0,0 +1,81 @@
+use anyhow;
+use gpui::Pixels;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
+#[serde(rename_all = "snake_case")]
+pub enum OutlinePanelDockPosition {
+ Left,
+ Right,
+}
+
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
+pub struct OutlinePanelSettings {
+ pub button: bool,
+ pub default_width: Pixels,
+ pub dock: OutlinePanelDockPosition,
+ pub file_icons: bool,
+ pub folder_icons: bool,
+ pub git_status: bool,
+ pub indent_size: f32,
+ pub auto_reveal_entries: bool,
+ pub auto_fold_dirs: bool,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct OutlinePanelSettingsContent {
+ /// Whether to show the outline panel button in the status bar.
+ ///
+ /// Default: true
+ pub button: Option,
+ /// Customise default width (in pixels) taken by outline panel
+ ///
+ /// Default: 240
+ pub default_width: Option,
+ /// The position of outline panel
+ ///
+ /// Default: left
+ pub dock: Option,
+ /// Whether to show file icons in the outline panel.
+ ///
+ /// Default: true
+ pub file_icons: Option,
+ /// Whether to show folder icons or chevrons for directories in the outline panel.
+ ///
+ /// Default: true
+ pub folder_icons: Option,
+ /// Whether to show the git status in the outline panel.
+ ///
+ /// Default: true
+ pub git_status: Option,
+ /// Amount of indentation (in pixels) for nested items.
+ ///
+ /// Default: 20
+ pub indent_size: Option,
+ /// Whether to reveal it in the outline panel automatically,
+ /// when a corresponding project entry becomes active.
+ /// Gitignored entries are never auto revealed.
+ ///
+ /// Default: true
+ pub auto_reveal_entries: Option,
+ /// Whether to fold directories automatically
+ /// when directory has only one directory inside.
+ ///
+ /// Default: true
+ pub auto_fold_dirs: Option,
+}
+
+impl Settings for OutlinePanelSettings {
+ const KEY: Option<&'static str> = Some("outline_panel");
+
+ type FileContent = OutlinePanelSettingsContent;
+
+ fn load(
+ sources: SettingsSources,
+ _: &mut gpui::AppContext,
+ ) -> anyhow::Result {
+ sources.json_merge()
+ }
+}
diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs
index 8103390eacfa9..1433e6069a04e 100644
--- a/crates/ui/src/components/icon.rs
+++ b/crates/ui/src/components/icon.rs
@@ -144,6 +144,7 @@ pub enum IconName {
InlayHint,
Library,
Link,
+ ListTree,
MagicWand,
MagnifyingGlass,
MailOpen,
@@ -274,6 +275,7 @@ impl IconName {
IconName::InlayHint => "icons/inlay_hint.svg",
IconName::Library => "icons/library.svg",
IconName::Link => "icons/link.svg",
+ IconName::ListTree => "icons/list_tree.svg",
IconName::MagicWand => "icons/magic_wand.svg",
IconName::MagnifyingGlass => "icons/magnifying_glass.svg",
IconName::MailOpen => "icons/mail_open.svg",
diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs
index 2a03e4be59513..2ac4bb9676c97 100644
--- a/crates/worktree/src/worktree.rs
+++ b/crates/worktree/src/worktree.rs
@@ -2000,7 +2000,7 @@ impl Snapshot {
}
}
- fn traverse_from_path(
+ pub fn traverse_from_path(
&self,
include_files: bool,
include_dirs: bool,
@@ -2991,7 +2991,7 @@ impl File {
}
}
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Entry {
pub id: ProjectEntryId,
pub kind: EntryKind,
@@ -3020,7 +3020,7 @@ pub struct Entry {
pub is_private: bool,
}
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EntryKind {
UnloadedDir,
PendingDir,
@@ -4818,6 +4818,14 @@ impl<'a> Traversal<'a> {
false
}
+ pub fn back_to_parent(&mut self) -> bool {
+ let Some(parent_path) = self.cursor.item().and_then(|entry| entry.path.parent()) else {
+ return false;
+ };
+ self.cursor
+ .seek(&TraversalTarget::Path(parent_path), Bias::Left, &())
+ }
+
pub fn entry(&self) -> Option<&'a Entry> {
self.cursor.item()
}
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index ee07acc8ebe2f..7ac8bd64b9dda 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -68,6 +68,7 @@ nix = {workspace = true, features = ["pthread", "signal"] }
node_runtime.workspace = true
notifications.workspace = true
outline.workspace = true
+outline_panel.workspace = true
parking_lot.workspace = true
profiling.workspace = true
project.workspace = true
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index 0ef0659ad4fa8..c3a39031fa32f 100644
--- a/crates/zed/src/main.rs
+++ b/crates/zed/src/main.rs
@@ -185,6 +185,7 @@ fn init_ui(app_state: Arc, cx: &mut AppContext) -> Result<()> {
outline::init(cx);
project_symbols::init(cx);
project_panel::init(Assets, cx);
+ outline_panel::init(Assets, cx);
tasks_ui::init(cx);
channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx);
search::init(cx);
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 5a80e99466a30..a39320277648e 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -18,6 +18,7 @@ pub use open_listener::*;
use anyhow::Context as _;
use assets::Assets;
use futures::{channel::mpsc, select_biased, StreamExt};
+use outline_panel::OutlinePanel;
use project::TaskSourceKind;
use project_panel::ProjectPanel;
use quick_action_bar::QuickActionBar;
@@ -190,6 +191,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) {
let assistant_panel =
assistant::AssistantPanel::load(workspace_handle.clone(), cx.clone());
let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
+ let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone());
let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
let channels_panel =
collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
@@ -202,6 +204,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) {
let (
project_panel,
+ outline_panel,
terminal_panel,
assistant_panel,
channels_panel,
@@ -209,6 +212,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) {
notification_panel,
) = futures::try_join!(
project_panel,
+ outline_panel,
terminal_panel,
assistant_panel,
channels_panel,
@@ -219,6 +223,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) {
workspace_handle.update(&mut cx, |workspace, cx| {
workspace.add_panel(assistant_panel, cx);
workspace.add_panel(project_panel, cx);
+ workspace.add_panel(outline_panel, cx);
workspace.add_panel(terminal_panel, cx);
workspace.add_panel(channels_panel, cx);
workspace.add_panel(chat_panel, cx);
@@ -377,6 +382,13 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) {
workspace.toggle_panel_focus::(cx);
},
)
+ .register_action(
+ |workspace: &mut Workspace,
+ _: &outline_panel::ToggleFocus,
+ cx: &mut ViewContext| {
+ workspace.toggle_panel_focus::(cx);
+ },
+ )
.register_action(
|workspace: &mut Workspace,
_: &collab_ui::collab_panel::ToggleFocus,
@@ -3093,9 +3105,9 @@ mod tests {
command_palette::init(cx);
language::init(cx);
editor::init(cx);
- project_panel::init_settings(cx);
collab_ui::init(&app_state, cx);
project_panel::init((), cx);
+ outline_panel::init((), cx);
terminal_view::init(cx);
assistant::init(app_state.client.clone(), cx);
tasks_ui::init(cx);
diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs
index 963948d207f44..8a12df90cb0b3 100644
--- a/crates/zed/src/zed/app_menus.rs
+++ b/crates/zed/src/zed/app_menus.rs
@@ -123,6 +123,7 @@ pub fn app_menus() -> Vec