Display external paths in history (#2534)

Kirill Bulatov created

Fixes
https://linear.app/zed-industries/issue/Z-1793/file-finder-external-recent-paths-are-not-rendered-properly

Long paths get trimmed, but same do many of our other elements, such as
type definitions, so I think it's ok for now:

![image](https://github.com/zed-industries/zed/assets/2690773/b8b6588d-6d6b-42db-9085-c741a40b7adb)

Also, we seem to do a lot of odd diagnostics handling on every external
stdlib file opened:

![image](https://github.com/zed-industries/zed/assets/2690773/cd82c54e-7849-46fe-a01c-79bfc5402b7b)
Other external files also emit similar messages, but not that much.
@\mikayla-maki mentioned, that this was happening before, so can be
fixed separately.
The PR adds path printing to these logs.

Release Notes:

* Fixed external files not being displayed properly in the recently
opened list in the file finder panel

Change summary

crates/ai/src/ai.rs                   |   2 
crates/file_finder/src/file_finder.rs | 652 +++++++++++++++++++++++-----
crates/project/src/project.rs         |   2 
crates/workspace/src/pane.rs          |  41 +
crates/workspace/src/workspace.rs     |  86 +++
5 files changed, 630 insertions(+), 153 deletions(-)

Detailed changes

crates/ai/src/ai.rs 🔗

@@ -240,7 +240,7 @@ impl Assistant {
             .assist_stacks
             .entry(cx.view_id())
             .or_default()
-            .push((dbg!(assist_id), assist_task));
+            .push((assist_id, assist_task));
 
         Ok(())
     }

crates/file_finder/src/file_finder.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
 use std::{
-    path::Path,
+    path::{Path, PathBuf},
     sync::{
         atomic::{self, AtomicBool},
         Arc,
@@ -25,11 +25,57 @@ pub struct FileFinderDelegate {
     latest_search_id: usize,
     latest_search_did_cancel: bool,
     latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
-    currently_opened_path: Option<ProjectPath>,
-    matches: Vec<PathMatch>,
-    selected: Option<(usize, Arc<Path>)>,
+    currently_opened_path: Option<FoundPath>,
+    matches: Matches,
+    selected_index: Option<usize>,
     cancel_flag: Arc<AtomicBool>,
-    history_items: Vec<ProjectPath>,
+    history_items: Vec<FoundPath>,
+}
+
+#[derive(Debug)]
+enum Matches {
+    History(Vec<FoundPath>),
+    Search(Vec<PathMatch>),
+}
+
+#[derive(Debug)]
+enum Match<'a> {
+    History(&'a FoundPath),
+    Search(&'a PathMatch),
+}
+
+impl Matches {
+    fn len(&self) -> usize {
+        match self {
+            Self::History(items) => items.len(),
+            Self::Search(items) => items.len(),
+        }
+    }
+
+    fn get(&self, index: usize) -> Option<Match<'_>> {
+        match self {
+            Self::History(items) => items.get(index).map(Match::History),
+            Self::Search(items) => items.get(index).map(Match::Search),
+        }
+    }
+}
+
+impl Default for Matches {
+    fn default() -> Self {
+        Self::History(Vec::new())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct FoundPath {
+    project: ProjectPath,
+    absolute: Option<PathBuf>,
+}
+
+impl FoundPath {
+    fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
+        Self { project, absolute }
+    }
 }
 
 actions!(file_finder, [Toggle]);
@@ -43,10 +89,41 @@ const MAX_RECENT_SELECTIONS: usize = 20;
 
 fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
     workspace.toggle_modal(cx, |workspace, cx| {
-        let history_items = workspace.recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx);
+        let project = workspace.project().read(cx);
+
         let currently_opened_path = workspace
             .active_item(cx)
-            .and_then(|item| item.project_path(cx));
+            .and_then(|item| item.project_path(cx))
+            .map(|project_path| {
+                let abs_path = project
+                    .worktree_for_id(project_path.worktree_id, cx)
+                    .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
+                FoundPath::new(project_path, abs_path)
+            });
+
+        // if exists, bubble the currently opened path to the top
+        let history_items = currently_opened_path
+            .clone()
+            .into_iter()
+            .chain(
+                workspace
+                    .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
+                    .into_iter()
+                    .filter(|(history_path, _)| {
+                        Some(history_path)
+                            != currently_opened_path
+                                .as_ref()
+                                .map(|found_path| &found_path.project)
+                    })
+                    .filter(|(_, history_abs_path)| {
+                        history_abs_path.as_ref()
+                            != currently_opened_path
+                                .as_ref()
+                                .and_then(|found_path| found_path.absolute.as_ref())
+                    })
+                    .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
+            )
+            .collect();
 
         let project = workspace.project().clone();
         let workspace = cx.handle().downgrade();
@@ -87,37 +164,11 @@ impl FileSearchQuery {
 }
 
 impl FileFinderDelegate {
-    fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
-        let path = &path_match.path;
-        let path_string = path.to_string_lossy();
-        let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
-        let path_positions = path_match.positions.clone();
-
-        let file_name = path.file_name().map_or_else(
-            || path_match.path_prefix.to_string(),
-            |file_name| file_name.to_string_lossy().to_string(),
-        );
-        let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
-            - file_name.chars().count();
-        let file_name_positions = path_positions
-            .iter()
-            .filter_map(|pos| {
-                if pos >= &file_name_start {
-                    Some(pos - file_name_start)
-                } else {
-                    None
-                }
-            })
-            .collect();
-
-        (file_name, file_name_positions, full_path, path_positions)
-    }
-
-    pub fn new(
+    fn new(
         workspace: WeakViewHandle<Workspace>,
         project: ModelHandle<Project>,
-        currently_opened_path: Option<ProjectPath>,
-        history_items: Vec<ProjectPath>,
+        currently_opened_path: Option<FoundPath>,
+        history_items: Vec<FoundPath>,
         cx: &mut ViewContext<FileFinder>,
     ) -> Self {
         cx.observe(&project, |picker, _, cx| {
@@ -132,8 +183,8 @@ impl FileFinderDelegate {
             latest_search_did_cancel: false,
             latest_search_query: None,
             currently_opened_path,
-            matches: Vec::new(),
-            selected: None,
+            matches: Matches::default(),
+            selected_index: None,
             cancel_flag: Arc::new(AtomicBool::new(false)),
             history_items,
         }
@@ -147,7 +198,7 @@ impl FileFinderDelegate {
         let relative_to = self
             .currently_opened_path
             .as_ref()
-            .map(|project_path| Arc::clone(&project_path.path));
+            .map(|found_path| Arc::clone(&found_path.project.path));
         let worktrees = self
             .project
             .read(cx)
@@ -188,13 +239,13 @@ impl FileFinderDelegate {
                 .update(&mut cx, |picker, cx| {
                     picker
                         .delegate_mut()
-                        .set_matches(search_id, did_cancel, query, matches, cx)
+                        .set_search_matches(search_id, did_cancel, query, matches, cx)
                 })
                 .log_err();
         })
     }
 
-    fn set_matches(
+    fn set_search_matches(
         &mut self,
         search_id: usize,
         did_cancel: bool,
@@ -211,15 +262,126 @@ impl FileFinderDelegate {
                         .as_ref()
                         .map(|query| query.path_like.path_query())
             {
-                util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
+                match &mut self.matches {
+                    Matches::History(_) => self.matches = Matches::Search(matches),
+                    Matches::Search(search_matches) => {
+                        util::extend_sorted(search_matches, matches.into_iter(), 100, |a, b| {
+                            b.cmp(a)
+                        })
+                    }
+                }
             } else {
-                self.matches = matches;
+                self.matches = Matches::Search(matches);
             }
             self.latest_search_query = Some(query);
             self.latest_search_did_cancel = did_cancel;
             cx.notify();
         }
     }
+
+    fn labels_for_match(
+        &self,
+        path_match: Match,
+        cx: &AppContext,
+        ix: usize,
+    ) -> (String, Vec<usize>, String, Vec<usize>) {
+        let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
+            Match::History(found_path) => {
+                let worktree_id = found_path.project.worktree_id;
+                let project_relative_path = &found_path.project.path;
+                let has_worktree = self
+                    .project
+                    .read(cx)
+                    .worktree_for_id(worktree_id, cx)
+                    .is_some();
+
+                if !has_worktree {
+                    if let Some(absolute_path) = &found_path.absolute {
+                        return (
+                            absolute_path
+                                .file_name()
+                                .map_or_else(
+                                    || project_relative_path.to_string_lossy(),
+                                    |file_name| file_name.to_string_lossy(),
+                                )
+                                .to_string(),
+                            Vec::new(),
+                            absolute_path.to_string_lossy().to_string(),
+                            Vec::new(),
+                        );
+                    }
+                }
+
+                let mut path = Arc::clone(project_relative_path);
+                if project_relative_path.as_ref() == Path::new("") {
+                    if let Some(absolute_path) = &found_path.absolute {
+                        path = Arc::from(absolute_path.as_path());
+                    }
+                }
+                self.labels_for_path_match(&PathMatch {
+                    score: ix as f64,
+                    positions: Vec::new(),
+                    worktree_id: worktree_id.to_usize(),
+                    path,
+                    path_prefix: "".into(),
+                    distance_to_relative_ancestor: usize::MAX,
+                })
+            }
+            Match::Search(path_match) => self.labels_for_path_match(path_match),
+        };
+
+        if file_name_positions.is_empty() {
+            if let Some(user_home_path) = std::env::var("HOME").ok() {
+                let user_home_path = user_home_path.trim();
+                if !user_home_path.is_empty() {
+                    if (&full_path).starts_with(user_home_path) {
+                        return (
+                            file_name,
+                            file_name_positions,
+                            full_path.replace(user_home_path, "~"),
+                            full_path_positions,
+                        );
+                    }
+                }
+            }
+        }
+
+        (
+            file_name,
+            file_name_positions,
+            full_path,
+            full_path_positions,
+        )
+    }
+
+    fn labels_for_path_match(
+        &self,
+        path_match: &PathMatch,
+    ) -> (String, Vec<usize>, String, Vec<usize>) {
+        let path = &path_match.path;
+        let path_string = path.to_string_lossy();
+        let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
+        let path_positions = path_match.positions.clone();
+
+        let file_name = path.file_name().map_or_else(
+            || path_match.path_prefix.to_string(),
+            |file_name| file_name.to_string_lossy().to_string(),
+        );
+        let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
+            - file_name.chars().count();
+        let file_name_positions = path_positions
+            .iter()
+            .filter_map(|pos| {
+                if pos >= &file_name_start {
+                    Some(pos - file_name_start)
+                } else {
+                    None
+                }
+            })
+            .collect();
+
+        (file_name, file_name_positions, full_path, path_positions)
+    }
 }
 
 impl PickerDelegate for FileFinderDelegate {
@@ -232,45 +394,35 @@ impl PickerDelegate for FileFinderDelegate {
     }
 
     fn selected_index(&self) -> usize {
-        if let Some(selected) = self.selected.as_ref() {
-            for (ix, path_match) in self.matches.iter().enumerate() {
-                if (path_match.worktree_id, path_match.path.as_ref())
-                    == (selected.0, selected.1.as_ref())
-                {
-                    return ix;
-                }
-            }
-        }
-        0
+        self.selected_index.unwrap_or(0)
     }
 
     fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<FileFinder>) {
-        let mat = &self.matches[ix];
-        self.selected = Some((mat.worktree_id, mat.path.clone()));
+        self.selected_index = Some(ix);
         cx.notify();
     }
 
     fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
         if raw_query.is_empty() {
+            let project = self.project.read(cx);
             self.latest_search_id = post_inc(&mut self.search_count);
-            self.matches.clear();
-
-            self.matches = self
-                .currently_opened_path
-                .iter() // if exists, bubble the currently opened path to the top
-                .chain(self.history_items.iter().filter(|history_item| {
-                    Some(*history_item) != self.currently_opened_path.as_ref()
-                }))
-                .enumerate()
-                .map(|(i, history_item)| PathMatch {
-                    score: i as f64,
-                    positions: Vec::new(),
-                    worktree_id: history_item.worktree_id.to_usize(),
-                    path: Arc::clone(&history_item.path),
-                    path_prefix: "".into(),
-                    distance_to_relative_ancestor: usize::MAX,
-                })
-                .collect();
+            self.matches = Matches::History(
+                self.history_items
+                    .iter()
+                    .filter(|history_item| {
+                        project
+                            .worktree_for_id(history_item.project.worktree_id, cx)
+                            .is_some()
+                            || (project.is_local()
+                                && history_item
+                                    .absolute
+                                    .as_ref()
+                                    .filter(|abs_path| abs_path.exists())
+                                    .is_some())
+                    })
+                    .cloned()
+                    .collect(),
+            );
             cx.notify();
             Task::ready(())
         } else {
@@ -293,16 +445,52 @@ impl PickerDelegate for FileFinderDelegate {
     fn confirm(&mut self, cx: &mut ViewContext<FileFinder>) {
         if let Some(m) = self.matches.get(self.selected_index()) {
             if let Some(workspace) = self.workspace.upgrade(cx) {
-                let project_path = ProjectPath {
-                    worktree_id: WorktreeId::from_usize(m.worktree_id),
-                    path: m.path.clone(),
-                };
-                let open_task = workspace.update(cx, |workspace, cx| {
-                    workspace.open_path(project_path.clone(), None, true, cx)
+                let open_task = workspace.update(cx, |workspace, cx| match m {
+                    Match::History(history_match) => {
+                        let worktree_id = history_match.project.worktree_id;
+                        if workspace
+                            .project()
+                            .read(cx)
+                            .worktree_for_id(worktree_id, cx)
+                            .is_some()
+                        {
+                            workspace.open_path(
+                                ProjectPath {
+                                    worktree_id,
+                                    path: Arc::clone(&history_match.project.path),
+                                },
+                                None,
+                                true,
+                                cx,
+                            )
+                        } else {
+                            match history_match.absolute.as_ref() {
+                                Some(abs_path) => {
+                                    workspace.open_abs_path(abs_path.to_path_buf(), false, cx)
+                                }
+                                None => workspace.open_path(
+                                    ProjectPath {
+                                        worktree_id,
+                                        path: Arc::clone(&history_match.project.path),
+                                    },
+                                    None,
+                                    true,
+                                    cx,
+                                ),
+                            }
+                        }
+                    }
+                    Match::Search(m) => workspace.open_path(
+                        ProjectPath {
+                            worktree_id: WorktreeId::from_usize(m.worktree_id),
+                            path: m.path.clone(),
+                        },
+                        None,
+                        true,
+                        cx,
+                    ),
                 });
 
-                let workspace = workspace.downgrade();
-
                 let row = self
                     .latest_search_query
                     .as_ref()
@@ -333,6 +521,7 @@ impl PickerDelegate for FileFinderDelegate {
                         }
                     }
                     workspace
+                        .downgrade()
                         .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx))
                         .log_err();
 
@@ -352,11 +541,14 @@ impl PickerDelegate for FileFinderDelegate {
         selected: bool,
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let path_match = &self.matches[ix];
+        let path_match = self
+            .matches
+            .get(ix)
+            .expect("Invalid matches state: no element for index {ix}");
         let theme = theme::current(cx);
         let style = theme.picker.item.style_for(mouse_state, selected);
         let (file_name, file_name_positions, full_path, full_path_positions) =
-            self.labels_for_match(path_match);
+            self.labels_for_match(path_match, cx, ix);
         Flex::column()
             .with_child(
                 Label::new(file_name, style.label.clone()).with_highlights(file_name_positions),
@@ -373,7 +565,7 @@ impl PickerDelegate for FileFinderDelegate {
 
 #[cfg(test)]
 mod tests {
-    use std::{assert_eq, collections::HashMap, time::Duration};
+    use std::{assert_eq, collections::HashMap, path::Path, time::Duration};
 
     use super::*;
     use editor::Editor;
@@ -649,12 +841,16 @@ mod tests {
 
         finder.update(cx, |finder, cx| {
             let delegate = finder.delegate_mut();
-            let matches = delegate.matches.clone();
+            let matches = match &delegate.matches {
+                Matches::Search(path_matches) => path_matches,
+                _ => panic!("Search matches expected"),
+            }
+            .clone();
 
             // Simulate a search being cancelled after the time limit,
             // returning only a subset of the matches that would have been found.
             drop(delegate.spawn_search(query.clone(), cx));
-            delegate.set_matches(
+            delegate.set_search_matches(
                 delegate.latest_search_id,
                 true, // did-cancel
                 query.clone(),
@@ -664,7 +860,7 @@ mod tests {
 
             // Simulate another cancellation.
             drop(delegate.spawn_search(query.clone(), cx));
-            delegate.set_matches(
+            delegate.set_search_matches(
                 delegate.latest_search_id,
                 true, // did-cancel
                 query.clone(),
@@ -672,7 +868,12 @@ mod tests {
                 cx,
             );
 
-            assert_eq!(delegate.matches, matches[0..4])
+            match &delegate.matches {
+                Matches::Search(new_matches) => {
+                    assert_eq!(new_matches.as_slice(), &matches[0..4])
+                }
+                _ => panic!("Search matches expected"),
+            };
         });
     }
 
@@ -772,10 +973,14 @@ mod tests {
         cx.read(|cx| {
             let finder = finder.read(cx);
             let delegate = finder.delegate();
-            assert_eq!(delegate.matches.len(), 1);
+            let matches = match &delegate.matches {
+                Matches::Search(path_matches) => path_matches,
+                _ => panic!("Search matches expected"),
+            };
+            assert_eq!(matches.len(), 1);
 
             let (file_name, file_name_positions, full_path, full_path_positions) =
-                delegate.labels_for_match(&delegate.matches[0]);
+                delegate.labels_for_path_match(&matches[0]);
             assert_eq!(file_name, "the-file");
             assert_eq!(file_name_positions, &[0, 1, 4]);
             assert_eq!(full_path, "the-file");
@@ -876,10 +1081,10 @@ mod tests {
         // When workspace has an active item, sort items which are closer to that item
         // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
         // so that one should be sorted earlier
-        let b_path = Some(ProjectPath {
+        let b_path = Some(dummy_found_path(ProjectPath {
             worktree_id,
             path: Arc::from(Path::new("/root/dir2/b.txt")),
-        });
+        }));
         let (_, finder) = cx.add_window(|cx| {
             Picker::new(
                 FileFinderDelegate::new(
@@ -901,8 +1106,12 @@ mod tests {
 
         finder.read_with(cx, |f, _| {
             let delegate = f.delegate();
-            assert_eq!(delegate.matches[0].path.as_ref(), Path::new("dir2/a.txt"));
-            assert_eq!(delegate.matches[1].path.as_ref(), Path::new("dir1/a.txt"));
+            let matches = match &delegate.matches {
+                Matches::Search(path_matches) => path_matches,
+                _ => panic!("Search matches expected"),
+            };
+            assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
+            assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
         });
     }
 
@@ -1012,10 +1221,13 @@ mod tests {
         .await;
         assert_eq!(
             history_after_first,
-            vec![ProjectPath {
-                worktree_id,
-                path: Arc::from(Path::new("test/first.rs")),
-            }],
+            vec![FoundPath::new(
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/first.rs")),
+                },
+                Some(PathBuf::from("/src/test/first.rs"))
+            )],
             "Should show 1st opened item in the history when opening the 2nd item"
         );
 
@@ -1032,14 +1244,20 @@ mod tests {
         assert_eq!(
             history_after_second,
             vec![
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/second.rs")),
-                },
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/first.rs")),
-                },
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/second.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/second.rs"))
+                ),
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/first.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/first.rs"))
+                ),
             ],
             "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
 2nd item should be the first in the history, as the last opened."
@@ -1058,18 +1276,27 @@ mod tests {
         assert_eq!(
             history_after_third,
             vec![
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/third.rs")),
-                },
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/second.rs")),
-                },
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/first.rs")),
-                },
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/third.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/third.rs"))
+                ),
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/second.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/second.rs"))
+                ),
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/first.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/first.rs"))
+                ),
             ],
             "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
 3rd item should be the first in the history, as the last opened."
@@ -1088,24 +1315,162 @@ mod tests {
         assert_eq!(
             history_after_second_again,
             vec![
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/second.rs")),
-                },
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/third.rs")),
-                },
-                ProjectPath {
-                    worktree_id,
-                    path: Arc::from(Path::new("test/first.rs")),
-                },
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/second.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/second.rs"))
+                ),
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/third.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/third.rs"))
+                ),
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/first.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/first.rs"))
+                ),
             ],
             "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
 2nd item, as the last opened, 3rd item should go next as it was opened right before."
         );
     }
 
+    #[gpui::test]
+    async fn test_external_files_history(
+        deterministic: Arc<gpui::executor::Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/src",
+                json!({
+                    "test": {
+                        "first.rs": "// First Rust file",
+                        "second.rs": "// Second Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/external-src",
+                json!({
+                    "test": {
+                        "third.rs": "// Third Rust file",
+                        "fourth.rs": "// Fourth Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+        cx.update(|cx| {
+            project.update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/external-src", false, cx)
+            })
+        })
+        .detach();
+        deterministic.run_until_parked();
+
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+        let worktree_id = cx.read(|cx| {
+            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1,);
+
+            WorktreeId::from_usize(worktrees[0].id())
+        });
+        workspace
+            .update(cx, |workspace, cx| {
+                workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
+            })
+            .detach();
+        deterministic.run_until_parked();
+        let external_worktree_id = cx.read(|cx| {
+            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(
+                worktrees.len(),
+                2,
+                "External file should get opened in a new worktree"
+            );
+
+            WorktreeId::from_usize(
+                worktrees
+                    .into_iter()
+                    .find(|worktree| worktree.id() != worktree_id.to_usize())
+                    .expect("New worktree should have a different id")
+                    .id(),
+            )
+        });
+        close_active_item(&workspace, &deterministic, cx).await;
+
+        let initial_history_items = open_close_queried_buffer(
+            "sec",
+            1,
+            "second.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert_eq!(
+            initial_history_items,
+            vec![FoundPath::new(
+                ProjectPath {
+                    worktree_id: external_worktree_id,
+                    path: Arc::from(Path::new("")),
+                },
+                Some(PathBuf::from("/external-src/test/third.rs"))
+            )],
+            "Should show external file with its full path in the history after it was open"
+        );
+
+        let updated_history_items = open_close_queried_buffer(
+            "fir",
+            1,
+            "first.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert_eq!(
+            updated_history_items,
+            vec![
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id,
+                        path: Arc::from(Path::new("test/second.rs")),
+                    },
+                    Some(PathBuf::from("/src/test/second.rs"))
+                ),
+                FoundPath::new(
+                    ProjectPath {
+                        worktree_id: external_worktree_id,
+                        path: Arc::from(Path::new("")),
+                    },
+                    Some(PathBuf::from("/external-src/test/third.rs"))
+                ),
+            ],
+            "Should keep external file with history updates",
+        );
+    }
+
     async fn open_close_queried_buffer(
         input: &str,
         expected_matches: usize,
@@ -1114,7 +1479,7 @@ mod tests {
         workspace: &ViewHandle<Workspace>,
         deterministic: &gpui::executor::Deterministic,
         cx: &mut gpui::TestAppContext,
-    ) -> Vec<ProjectPath> {
+    ) -> Vec<FoundPath> {
         cx.dispatch_action(window_id, Toggle);
         let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
         finder
@@ -1152,6 +1517,16 @@ mod tests {
             );
         });
 
+        close_active_item(workspace, deterministic, cx).await;
+
+        history_items
+    }
+
+    async fn close_active_item(
+        workspace: &ViewHandle<Workspace>,
+        deterministic: &gpui::executor::Deterministic,
+        cx: &mut TestAppContext,
+    ) {
         let mut original_items = HashMap::new();
         cx.read(|cx| {
             for pane in workspace.read(cx).panes() {
@@ -1161,6 +1536,8 @@ mod tests {
                 assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
             }
         });
+
+        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
         active_pane
             .update(cx, |pane, cx| {
                 pane.close_active_item(&workspace::CloseActiveItem, cx)
@@ -1185,8 +1562,10 @@ mod tests {
                 }
             }
         });
-
-        history_items
+        assert!(
+            original_items.len() <= 1,
+            "At most one panel should got closed"
+        );
     }
 
     fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
@@ -1216,4 +1595,11 @@ mod tests {
         })
         .unwrap()
     }
+
+    fn dummy_found_path(project_path: ProjectPath) -> FoundPath {
+        FoundPath {
+            project: project_path,
+            absolute: None,
+        }
+    }
 }

crates/project/src/project.rs 🔗

@@ -3218,7 +3218,7 @@ impl Project {
     ) -> Result<(), anyhow::Error> {
         let (worktree, relative_path) = self
             .find_local_worktree(&abs_path, cx)
-            .ok_or_else(|| anyhow!("no worktree found for diagnostics"))?;
+            .ok_or_else(|| anyhow!("no worktree found for diagnostics path {abs_path:?}"))?;
 
         let project_path = ProjectPath {
             worktree_id: worktree.read(cx).id(),

crates/workspace/src/pane.rs 🔗

@@ -31,7 +31,7 @@ use std::{
     any::Any,
     cell::RefCell,
     cmp, mem,
-    path::Path,
+    path::{Path, PathBuf},
     rc::Rc,
     sync::{
         atomic::{AtomicUsize, Ordering},
@@ -180,7 +180,7 @@ struct NavHistory {
     backward_stack: VecDeque<NavigationEntry>,
     forward_stack: VecDeque<NavigationEntry>,
     closed_stack: VecDeque<NavigationEntry>,
-    paths_by_item: HashMap<usize, ProjectPath>,
+    paths_by_item: HashMap<usize, (ProjectPath, Option<PathBuf>)>,
     pane: WeakViewHandle<Pane>,
     next_timestamp: Arc<AtomicUsize>,
 }
@@ -482,7 +482,7 @@ impl Pane {
                             .paths_by_item
                             .get(&entry.item.id())
                             .cloned()
-                            .map(|project_path| (project_path, entry));
+                            .map(|(project_path, _)| (project_path, entry));
                     }
                 }
             })
@@ -591,6 +591,20 @@ impl Pane {
         destination_index: Option<usize>,
         cx: &mut ViewContext<Workspace>,
     ) {
+        if item.is_singleton(cx) {
+            if let Some(&entry_id) = item.project_entry_ids(cx).get(0) {
+                if let Some(project_path) =
+                    workspace.project().read(cx).path_for_entry(entry_id, cx)
+                {
+                    let abs_path = workspace.absolute_path(&project_path, cx);
+                    pane.read(cx)
+                        .nav_history
+                        .borrow_mut()
+                        .paths_by_item
+                        .insert(item.id(), (project_path, abs_path));
+                }
+            }
+        }
         // If no destination index is specified, add or move the item after the active item.
         let mut insertion_index = {
             let pane = pane.read(cx);
@@ -1003,10 +1017,16 @@ impl Pane {
             .set_mode(NavigationMode::Normal);
 
         if let Some(path) = item.project_path(cx) {
+            let abs_path = self
+                .nav_history
+                .borrow()
+                .paths_by_item
+                .get(&item.id())
+                .and_then(|(_, abs_path)| abs_path.clone());
             self.nav_history
                 .borrow_mut()
                 .paths_by_item
-                .insert(item.id(), path);
+                .insert(item.id(), (path, abs_path));
         } else {
             self.nav_history
                 .borrow_mut()
@@ -1954,7 +1974,7 @@ impl PaneNavHistory {
     pub fn for_each_entry(
         &self,
         cx: &AppContext,
-        mut f: impl FnMut(&NavigationEntry, ProjectPath),
+        mut f: impl FnMut(&NavigationEntry, (ProjectPath, Option<PathBuf>)),
     ) {
         let borrowed_history = self.0.borrow();
         borrowed_history
@@ -1963,12 +1983,13 @@ impl PaneNavHistory {
             .chain(borrowed_history.backward_stack.iter())
             .chain(borrowed_history.closed_stack.iter())
             .for_each(|entry| {
-                if let Some(path) = borrowed_history.paths_by_item.get(&entry.item.id()) {
-                    f(entry, path.clone());
+                if let Some(project_and_abs_path) =
+                    borrowed_history.paths_by_item.get(&entry.item.id())
+                {
+                    f(entry, project_and_abs_path.clone());
                 } else if let Some(item) = entry.item.upgrade(cx) {
-                    let path = item.project_path(cx);
-                    if let Some(path) = path {
-                        f(entry, path);
+                    if let Some(path) = item.project_path(cx) {
+                        f(entry, (path, None));
                     }
                 }
             })

crates/workspace/src/workspace.rs 🔗

@@ -946,21 +946,30 @@ impl Workspace {
         &self,
         limit: Option<usize>,
         cx: &AppContext,
-    ) -> Vec<ProjectPath> {
-        let mut history: HashMap<ProjectPath, usize> = HashMap::default();
+    ) -> Vec<(ProjectPath, Option<PathBuf>)> {
+        let mut abs_paths_opened: HashMap<PathBuf, HashSet<ProjectPath>> = HashMap::default();
+        let mut history: HashMap<ProjectPath, (Option<PathBuf>, usize)> = HashMap::default();
         for pane in &self.panes {
             let pane = pane.read(cx);
             pane.nav_history()
-                .for_each_entry(cx, |entry, project_path| {
+                .for_each_entry(cx, |entry, (project_path, fs_path)| {
+                    if let Some(fs_path) = &fs_path {
+                        abs_paths_opened
+                            .entry(fs_path.clone())
+                            .or_default()
+                            .insert(project_path.clone());
+                    }
                     let timestamp = entry.timestamp;
                     match history.entry(project_path) {
                         hash_map::Entry::Occupied(mut entry) => {
-                            if &timestamp > entry.get() {
-                                entry.insert(timestamp);
+                            let (old_fs_path, old_timestamp) = entry.get();
+                            if &timestamp > old_timestamp {
+                                assert_eq!(&fs_path, old_fs_path, "Inconsistent nav history");
+                                entry.insert((fs_path, timestamp));
                             }
                         }
                         hash_map::Entry::Vacant(entry) => {
-                            entry.insert(timestamp);
+                            entry.insert((fs_path, timestamp));
                         }
                     }
                 });
@@ -968,9 +977,24 @@ impl Workspace {
 
         history
             .into_iter()
-            .sorted_by_key(|(_, timestamp)| *timestamp)
-            .map(|(project_path, _)| project_path)
+            .sorted_by_key(|(_, (_, timestamp))| *timestamp)
+            .map(|(project_path, (fs_path, _))| (project_path, fs_path))
             .rev()
+            .filter(|(history_path, abs_path)| {
+                let latest_project_path_opened = abs_path
+                    .as_ref()
+                    .and_then(|abs_path| abs_paths_opened.get(abs_path))
+                    .and_then(|project_paths| {
+                        project_paths
+                            .iter()
+                            .max_by(|b1, b2| b1.worktree_id.cmp(&b2.worktree_id))
+                    });
+
+                match latest_project_path_opened {
+                    Some(latest_project_path_opened) => latest_project_path_opened == history_path,
+                    None => true,
+                }
+            })
             .take(limit.unwrap_or(usize::MAX))
             .collect()
     }
@@ -1283,6 +1307,22 @@ impl Workspace {
         })
     }
 
+    pub fn absolute_path(&self, project_path: &ProjectPath, cx: &AppContext) -> Option<PathBuf> {
+        let workspace_root = self
+            .project()
+            .read(cx)
+            .worktree_for_id(project_path.worktree_id, cx)?
+            .read(cx)
+            .abs_path();
+        let project_path = project_path.path.as_ref();
+
+        Some(if project_path == Path::new("") {
+            workspace_root.to_path_buf()
+        } else {
+            workspace_root.join(project_path)
+        })
+    }
+
     fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext<Self>) {
         let mut paths = cx.prompt_for_paths(PathPromptOptions {
             files: false,
@@ -1582,6 +1622,36 @@ impl Workspace {
         Pane::add_item(self, &active_pane, item, true, true, None, cx);
     }
 
+    pub fn open_abs_path(
+        &mut self,
+        abs_path: PathBuf,
+        visible: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<anyhow::Result<Box<dyn ItemHandle>>> {
+        cx.spawn(|workspace, mut cx| async move {
+            let open_paths_task_result = workspace
+                .update(&mut cx, |workspace, cx| {
+                    workspace.open_paths(vec![abs_path.clone()], visible, cx)
+                })
+                .with_context(|| format!("open abs path {abs_path:?} task spawn"))?
+                .await;
+            anyhow::ensure!(
+                open_paths_task_result.len() == 1,
+                "open abs path {abs_path:?} task returned incorrect number of results"
+            );
+            match open_paths_task_result
+                .into_iter()
+                .next()
+                .expect("ensured single task result")
+            {
+                Some(open_result) => {
+                    open_result.with_context(|| format!("open abs path {abs_path:?} task join"))
+                }
+                None => anyhow::bail!("open abs path {abs_path:?} task returned None"),
+            }
+        })
+    }
+
     pub fn open_path(
         &mut self,
         path: impl Into<ProjectPath>,