vim: Add 'gf' command, make files cmd-clickable (#16534)

Thorsten Ball created

Release Notes:

- vim: Added `gf` command to open files under the cursor.
- Filenames can now be `cmd`/`ctrl`-clicked, which opens them.

TODOs:

- [x] `main_test.go` <-- works
- [x] `./my-pkg/my_pkg.go` <-- works
- [x] `../go.mod` <-- works
- [x] `my-pkg/my_pkg.go` <-- works
- [x] `my-pkg/subpkg/subpkg_test.go` <-- works
- [x] `file\ with\ space\ in\ it.txt` <-- works
- [x] `"file\ with\ space\ in\ it.txt"` <-- works
- [x] `"main_test.go"` <-- works
- [x] `/Users/thorstenball/.vimrc` <-- works, but only locally
- [x] `~/.vimrc` <--works, but only locally
- [x] Get it working over collab
- [x] Get hover links working

Demo:



https://github.com/user-attachments/assets/26af7f3b-c392-4aaf-849a-95d6c3b00067

Collab demo:




https://github.com/user-attachments/assets/272598bd-0e82-4556-8f9c-ba53d3a95682

Change summary

assets/keymaps/vim.json           |   1 
crates/editor/src/actions.rs      |   1 
crates/editor/src/editor.rs       |  76 ++++++
crates/editor/src/element.rs      |   1 
crates/editor/src/hover_links.rs  | 370 ++++++++++++++++++++++++++++++--
crates/language/src/buffer.rs     |   2 
crates/project/src/project.rs     |  97 ++++++++
crates/vim/src/command.rs         |  59 +++++
crates/workspace/src/workspace.rs |  15 +
9 files changed, 579 insertions(+), 43 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -92,6 +92,7 @@
       "g y": "editor::GoToTypeDefinition",
       "g shift-i": "editor::GoToImplementation",
       "g x": "editor::OpenUrl",
+      "g f": "editor::OpenFile",
       "g n": "vim::SelectNextMatch",
       "g shift-n": "vim::SelectPreviousMatch",
       "g l": "vim::SelectNext",

crates/editor/src/editor.rs 🔗

@@ -99,7 +99,7 @@ use language::{point_to_lsp, BufferRow, Runnable, RunnableRange};
 use linked_editing_ranges::refresh_linked_ranges;
 use task::{ResolvedTask, TaskTemplate, TaskVariables};
 
-use hover_links::{HoverLink, HoveredLinkState, InlayHighlight};
+use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight};
 pub use lsp::CompletionContext;
 use lsp::{
     CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, InsertTextFormat,
@@ -9179,6 +9179,38 @@ impl Editor {
         .detach();
     }
 
+    pub fn open_file(&mut self, _: &OpenFile, cx: &mut ViewContext<Self>) {
+        let Some(workspace) = self.workspace() else {
+            return;
+        };
+
+        let position = self.selections.newest_anchor().head();
+
+        let Some((buffer, buffer_position)) =
+            self.buffer.read(cx).text_anchor_for_position(position, cx)
+        else {
+            return;
+        };
+
+        let Some(project) = self.project.clone() else {
+            return;
+        };
+
+        cx.spawn(|_, mut cx| async move {
+            let result = find_file(&buffer, project, buffer_position, &mut cx).await;
+
+            if let Some((_, path)) = result {
+                workspace
+                    .update(&mut cx, |workspace, cx| {
+                        workspace.open_resolved_path(path, cx)
+                    })?
+                    .await?;
+            }
+            anyhow::Ok(())
+        })
+        .detach();
+    }
+
     pub(crate) fn navigate_to_hover_links(
         &mut self,
         kind: Option<GotoDefinitionKind>,
@@ -9189,21 +9221,49 @@ impl Editor {
         // If there is one definition, just open it directly
         if definitions.len() == 1 {
             let definition = definitions.pop().unwrap();
+
+            enum TargetTaskResult {
+                Location(Option<Location>),
+                AlreadyNavigated,
+            }
+
             let target_task = match definition {
-                HoverLink::Text(link) => Task::Ready(Some(Ok(Some(link.target)))),
+                HoverLink::Text(link) => {
+                    Task::ready(anyhow::Ok(TargetTaskResult::Location(Some(link.target))))
+                }
                 HoverLink::InlayHint(lsp_location, server_id) => {
-                    self.compute_target_location(lsp_location, server_id, cx)
+                    let computation = self.compute_target_location(lsp_location, server_id, cx);
+                    cx.background_executor().spawn(async move {
+                        let location = computation.await?;
+                        Ok(TargetTaskResult::Location(location))
+                    })
                 }
                 HoverLink::Url(url) => {
                     cx.open_url(&url);
-                    Task::ready(Ok(None))
+                    Task::ready(Ok(TargetTaskResult::AlreadyNavigated))
+                }
+                HoverLink::File(path) => {
+                    if let Some(workspace) = self.workspace() {
+                        cx.spawn(|_, mut cx| async move {
+                            workspace
+                                .update(&mut cx, |workspace, cx| {
+                                    workspace.open_resolved_path(path, cx)
+                                })?
+                                .await
+                                .map(|_| TargetTaskResult::AlreadyNavigated)
+                        })
+                    } else {
+                        Task::ready(Ok(TargetTaskResult::Location(None)))
+                    }
                 }
             };
             cx.spawn(|editor, mut cx| async move {
-                let target = target_task.await.context("target resolution task")?;
-                let Some(target) = target else {
-                    return Ok(Navigated::No);
+                let target = match target_task.await.context("target resolution task")? {
+                    TargetTaskResult::AlreadyNavigated => return Ok(Navigated::Yes),
+                    TargetTaskResult::Location(None) => return Ok(Navigated::No),
+                    TargetTaskResult::Location(Some(target)) => target,
                 };
+
                 editor.update(&mut cx, |editor, cx| {
                     let Some(workspace) = editor.workspace() else {
                         return Navigated::No;
@@ -9281,6 +9341,7 @@ impl Editor {
                                 }),
                                 HoverLink::InlayHint(_, _) => None,
                                 HoverLink::Url(_) => None,
+                                HoverLink::File(_) => None,
                             })
                             .unwrap_or(tab_kind.to_string());
                         let location_tasks = definitions
@@ -9291,6 +9352,7 @@ impl Editor {
                                     editor.compute_target_location(lsp_location, server_id, cx)
                                 }
                                 HoverLink::Url(_) => Task::ready(Ok(None)),
+                                HoverLink::File(_) => Task::ready(Ok(None)),
                             })
                             .collect::<Vec<_>>();
                         (title, location_tasks, editor.workspace().clone())

crates/editor/src/element.rs 🔗

@@ -331,6 +331,7 @@ impl EditorElement {
                 .detach_and_log_err(cx);
         });
         register_action(view, cx, Editor::open_url);
+        register_action(view, cx, Editor::open_file);
         register_action(view, cx, Editor::fold);
         register_action(view, cx, Editor::fold_at);
         register_action(view, cx, Editor::unfold_lines);

crates/editor/src/hover_links.rs 🔗

@@ -9,8 +9,8 @@ use language::{Bias, ToOffset};
 use linkify::{LinkFinder, LinkKind};
 use lsp::LanguageServerId;
 use project::{
-    HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink,
-    ResolveState,
+    HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project,
+    ResolveState, ResolvedPath,
 };
 use std::ops::Range;
 use theme::ActiveTheme as _;
@@ -63,6 +63,7 @@ impl RangeInEditor {
 #[derive(Debug, Clone)]
 pub enum HoverLink {
     Url(String),
+    File(ResolvedPath),
     Text(LocationLink),
     InlayHint(lsp::Location, LanguageServerId),
 }
@@ -522,35 +523,54 @@ pub fn show_link_definition(
                         })
                         .ok()
                     } else if let Some(project) = project {
-                        // query the LSP for definition info
-                        project
-                            .update(&mut cx, |project, cx| match preferred_kind {
-                                LinkDefinitionKind::Symbol => {
-                                    project.definition(&buffer, buffer_position, cx)
-                                }
+                        if let Some((filename_range, filename)) =
+                            find_file(&buffer, project.clone(), buffer_position, &mut cx).await
+                        {
+                            let range = maybe!({
+                                let start =
+                                    snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?;
+                                let end =
+                                    snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?;
+                                Some(RangeInEditor::Text(start..end))
+                            });
 
-                                LinkDefinitionKind::Type => {
-                                    project.type_definition(&buffer, buffer_position, cx)
-                                }
-                            })?
-                            .await
-                            .ok()
-                            .map(|definition_result| {
-                                (
-                                    definition_result.iter().find_map(|link| {
-                                        link.origin.as_ref().and_then(|origin| {
-                                            let start = snapshot.anchor_in_excerpt(
-                                                excerpt_id,
-                                                origin.range.start,
-                                            )?;
-                                            let end = snapshot
-                                                .anchor_in_excerpt(excerpt_id, origin.range.end)?;
-                                            Some(RangeInEditor::Text(start..end))
-                                        })
-                                    }),
-                                    definition_result.into_iter().map(HoverLink::Text).collect(),
-                                )
-                            })
+                            Some((range, vec![HoverLink::File(filename)]))
+                        } else {
+                            // query the LSP for definition info
+                            project
+                                .update(&mut cx, |project, cx| match preferred_kind {
+                                    LinkDefinitionKind::Symbol => {
+                                        project.definition(&buffer, buffer_position, cx)
+                                    }
+
+                                    LinkDefinitionKind::Type => {
+                                        project.type_definition(&buffer, buffer_position, cx)
+                                    }
+                                })?
+                                .await
+                                .ok()
+                                .map(|definition_result| {
+                                    (
+                                        definition_result.iter().find_map(|link| {
+                                            link.origin.as_ref().and_then(|origin| {
+                                                let start = snapshot.anchor_in_excerpt(
+                                                    excerpt_id,
+                                                    origin.range.start,
+                                                )?;
+                                                let end = snapshot.anchor_in_excerpt(
+                                                    excerpt_id,
+                                                    origin.range.end,
+                                                )?;
+                                                Some(RangeInEditor::Text(start..end))
+                                            })
+                                        }),
+                                        definition_result
+                                            .into_iter()
+                                            .map(HoverLink::Text)
+                                            .collect(),
+                                    )
+                                })
+                        }
                     } else {
                         None
                     }
@@ -686,6 +706,116 @@ pub(crate) fn find_url(
     None
 }
 
+pub(crate) async fn find_file(
+    buffer: &Model<language::Buffer>,
+    project: Model<Project>,
+    position: text::Anchor,
+    cx: &mut AsyncWindowContext,
+) -> Option<(Range<text::Anchor>, ResolvedPath)> {
+    let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()).ok()?;
+
+    let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
+
+    let existing_path = project
+        .update(cx, |project, cx| {
+            project.resolve_existing_file_path(&candidate_file_path, &buffer, cx)
+        })
+        .ok()?
+        .await?;
+
+    Some((range, existing_path))
+}
+
+fn surrounding_filename(
+    snapshot: language::BufferSnapshot,
+    position: text::Anchor,
+) -> Option<(Range<text::Anchor>, String)> {
+    const LIMIT: usize = 2048;
+
+    let offset = position.to_offset(&snapshot);
+    let mut token_start = offset;
+    let mut token_end = offset;
+    let mut found_start = false;
+    let mut found_end = false;
+    let mut inside_quotes = false;
+
+    let mut filename = String::new();
+
+    let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
+    while let Some(ch) = backwards.next() {
+        // Escaped whitespace
+        if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
+            filename.push(ch);
+            token_start -= ch.len_utf8();
+            backwards.next();
+            token_start -= '\\'.len_utf8();
+            continue;
+        }
+        if ch.is_whitespace() {
+            found_start = true;
+            break;
+        }
+        if (ch == '"' || ch == '\'') && !inside_quotes {
+            found_start = true;
+            inside_quotes = true;
+            break;
+        }
+
+        filename.push(ch);
+        token_start -= ch.len_utf8();
+    }
+    if !found_start && token_start != 0 {
+        return None;
+    }
+
+    filename = filename.chars().rev().collect();
+
+    let mut forwards = snapshot
+        .chars_at(offset)
+        .take(LIMIT - (offset - token_start))
+        .peekable();
+    while let Some(ch) = forwards.next() {
+        // Skip escaped whitespace
+        if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) {
+            token_end += ch.len_utf8();
+            let whitespace = forwards.next().unwrap();
+            token_end += whitespace.len_utf8();
+            filename.push(whitespace);
+            continue;
+        }
+
+        if ch.is_whitespace() {
+            found_end = true;
+            break;
+        }
+        if ch == '"' || ch == '\'' {
+            // If we're inside quotes, we stop when we come across the next quote
+            if inside_quotes {
+                found_end = true;
+                break;
+            } else {
+                // Otherwise, we skip the quote
+                inside_quotes = true;
+                continue;
+            }
+        }
+        filename.push(ch);
+        token_end += ch.len_utf8();
+    }
+
+    if !found_end && (token_end - token_start >= LIMIT) {
+        return None;
+    }
+
+    if filename.is_empty() {
+        return None;
+    }
+
+    let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
+
+    Some((range, filename))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1268,4 +1398,184 @@ mod tests {
         cx.simulate_click(screen_coord, Modifiers::secondary_key());
         assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
     }
+
+    #[gpui::test]
+    async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        let test_cases = [
+            ("file ˇ name", None),
+            ("ˇfile name", Some("file")),
+            ("file ˇname", Some("name")),
+            ("fiˇle name", Some("file")),
+            ("filenˇame", Some("filename")),
+            // Absolute path
+            ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
+            ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
+            // Windows
+            ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
+            // Whitespace
+            ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
+            ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
+            // Tilde
+            ("ˇ~/file.txt", Some("~/file.txt")),
+            ("~/fiˇle.txt", Some("~/file.txt")),
+            // Double quotes
+            ("\"fˇile.txt\"", Some("file.txt")),
+            ("ˇ\"file.txt\"", Some("file.txt")),
+            ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
+            // Single quotes
+            ("'fˇile.txt'", Some("file.txt")),
+            ("ˇ'file.txt'", Some("file.txt")),
+            ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
+        ];
+
+        for (input, expected) in test_cases {
+            cx.set_state(input);
+
+            let (position, snapshot) = cx.editor(|editor, cx| {
+                let positions = editor.selections.newest_anchor().head().text_anchor;
+                let snapshot = editor
+                    .buffer()
+                    .clone()
+                    .read(cx)
+                    .as_singleton()
+                    .unwrap()
+                    .read(cx)
+                    .snapshot();
+                (positions, snapshot)
+            });
+
+            let result = surrounding_filename(snapshot, position);
+
+            if let Some(expected) = expected {
+                assert!(result.is_some(), "Failed to find file path: {}", input);
+                let (_, path) = result.unwrap();
+                assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
+            } else {
+                assert!(
+                    result.is_none(),
+                    "Expected no result, but got one: {:?}",
+                    result
+                );
+            }
+        }
+    }
+
+    #[gpui::test]
+    async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        // Insert a new file
+        let fs = cx.update_workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
+        fs.as_fake()
+            .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
+            .await;
+
+        cx.set_state(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.ˇ
+        "});
+
+        // File does not exist
+        let screen_coord = cx.pixel_position(indoc! {"
+            You can't go to a file that dˇoes_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.
+        "});
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        // No highlight
+        cx.update_editor(|editor, cx| {
+            assert!(editor
+                .snapshot(cx)
+                .text_highlight_ranges::<HoveredLinkState>()
+                .unwrap_or_default()
+                .1
+                .is_empty());
+        });
+
+        // Moving the mouse over a file that does exist should highlight it.
+        let screen_coord = cx.pixel_position(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to fˇile2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to «file2.rsˇ» if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.
+        "});
+
+        // Moving the mouse over a relative path that does exist should highlight it
+        let screen_coord = cx.pixel_position(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/fˇile2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to «../dir/file2.rsˇ» if you want.
+            Or go to /root/dir/file2.rs if project is local.
+        "});
+
+        // Moving the mouse over an absolute path that does exist should highlight it
+        let screen_coord = cx.pixel_position(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/diˇr/file2.rs if project is local.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to «/root/dir/file2.rsˇ» if project is local.
+        "});
+
+        cx.simulate_click(screen_coord, Modifiers::secondary_key());
+
+        cx.update_workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
+        cx.update_workspace(|workspace, cx| {
+            let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
+
+            let buffer = active_editor
+                .read(cx)
+                .buffer()
+                .read(cx)
+                .as_singleton()
+                .unwrap();
+
+            let file = buffer.read(cx).file().unwrap();
+            let file_path = file.as_local().unwrap().abs_path(cx);
+
+            assert_eq!(file_path.to_str().unwrap(), "/root/dir/file2.rs");
+        });
+    }
 }

crates/language/src/buffer.rs 🔗

@@ -383,7 +383,7 @@ pub trait File: Send + Sync {
 
 /// The file associated with a buffer, in the case where the file is on the local disk.
 pub trait LocalFile: File {
-    /// Returns the absolute path of this file.
+    /// Returns the absolute path of this file
     fn abs_path(&self, cx: &AppContext) -> PathBuf;
 
     /// Loads the file's contents from disk.

crates/project/src/project.rs 🔗

@@ -649,16 +649,17 @@ impl DirectoryLister {
         };
         "~/".to_string()
     }
-    pub fn list_directory(&self, query: String, cx: &mut AppContext) -> Task<Result<Vec<PathBuf>>> {
+
+    pub fn list_directory(&self, path: String, cx: &mut AppContext) -> Task<Result<Vec<PathBuf>>> {
         match self {
             DirectoryLister::Project(project) => {
-                project.update(cx, |project, cx| project.list_directory(query, cx))
+                project.update(cx, |project, cx| project.list_directory(path, cx))
             }
             DirectoryLister::Local(fs) => {
                 let fs = fs.clone();
                 cx.background_executor().spawn(async move {
                     let mut results = vec![];
-                    let expanded = shellexpand::tilde(&query);
+                    let expanded = shellexpand::tilde(&path);
                     let query = Path::new(expanded.as_ref());
                     let mut response = fs.read_dir(query).await?;
                     while let Some(path) = response.next().await {
@@ -7769,6 +7770,88 @@ impl Project {
         }
     }
 
+    // Returns the resolved version of `path`, that was found in `buffer`, if it exists.
+    pub fn resolve_existing_file_path(
+        &self,
+        path: &str,
+        buffer: &Model<Buffer>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Option<ResolvedPath>> {
+        // TODO: ssh based remoting.
+        if self.ssh_session.is_some() {
+            return Task::ready(None);
+        }
+
+        if self.is_local() {
+            let expanded = PathBuf::from(shellexpand::tilde(&path).into_owned());
+
+            if expanded.is_absolute() {
+                let fs = self.fs.clone();
+                cx.background_executor().spawn(async move {
+                    let path = expanded.as_path();
+                    let exists = fs.is_file(path).await;
+
+                    exists.then(|| ResolvedPath::AbsPath(expanded))
+                })
+            } else {
+                self.resolve_path_in_worktrees(expanded, buffer, cx)
+            }
+        } else {
+            let path = PathBuf::from(path);
+            if path.is_absolute() || path.starts_with("~") {
+                return Task::ready(None);
+            }
+
+            self.resolve_path_in_worktrees(path, buffer, cx)
+        }
+    }
+
+    fn resolve_path_in_worktrees(
+        &self,
+        path: PathBuf,
+        buffer: &Model<Buffer>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Option<ResolvedPath>> {
+        let mut candidates = vec![path.clone()];
+
+        if let Some(file) = buffer.read(cx).file() {
+            if let Some(dir) = file.path().parent() {
+                let joined = dir.to_path_buf().join(path);
+                candidates.push(joined);
+            }
+        }
+
+        let worktrees = self.worktrees(cx).collect::<Vec<_>>();
+        cx.spawn(|_, mut cx| async move {
+            for worktree in worktrees {
+                for candidate in candidates.iter() {
+                    let path = worktree
+                        .update(&mut cx, |worktree, _| {
+                            let root_entry_path = &worktree.root_entry().unwrap().path;
+
+                            let resolved = resolve_path(&root_entry_path, candidate);
+
+                            let stripped =
+                                resolved.strip_prefix(&root_entry_path).unwrap_or(&resolved);
+
+                            worktree.entry_for_path(stripped).map(|entry| {
+                                ResolvedPath::ProjectPath(ProjectPath {
+                                    worktree_id: worktree.id(),
+                                    path: entry.path.clone(),
+                                })
+                            })
+                        })
+                        .ok()?;
+
+                    if path.is_some() {
+                        return path;
+                    }
+                }
+            }
+            None
+        })
+    }
+
     pub fn list_directory(
         &self,
         query: String,
@@ -11230,6 +11313,14 @@ fn resolve_path(base: &Path, path: &Path) -> PathBuf {
     result
 }
 
+/// ResolvedPath is a path that has been resolved to either a ProjectPath
+/// or an AbsPath and that *exists*.
+#[derive(Debug, Clone)]
+pub enum ResolvedPath {
+    ProjectPath(ProjectPath),
+    AbsPath(PathBuf),
+}
+
 impl Item for Buffer {
     fn try_open(
         project: &Model<Project>,

crates/vim/src/command.rs 🔗

@@ -742,9 +742,15 @@ fn generate_positions(string: &str, query: &str) -> Vec<usize> {
 mod test {
     use std::path::Path;
 
-    use crate::test::{NeovimBackedTestContext, VimTestContext};
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
+    use editor::Editor;
     use gpui::TestAppContext;
     use indoc::indoc;
+    use ui::ViewContext;
+    use workspace::Workspace;
 
     #[gpui::test]
     async fn test_command_basics(cx: &mut TestAppContext) {
@@ -923,4 +929,55 @@ mod test {
             .await;
         cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
     }
+
+    fn assert_active_item(
+        workspace: &mut Workspace,
+        expected_path: &str,
+        expected_text: &str,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
+
+        let buffer = active_editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap();
+
+        let text = buffer.read(cx).text();
+        let file = buffer.read(cx).file().unwrap();
+        let file_path = file.as_local().unwrap().abs_path(cx);
+
+        assert_eq!(text, expected_text);
+        assert_eq!(file_path.to_str().unwrap(), expected_path);
+    }
+
+    #[gpui::test]
+    async fn test_command_gf(cx: &mut TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        // Assert base state, that we're in /root/dir/file.rs
+        cx.workspace(|workspace, cx| {
+            assert_active_item(workspace, "/root/dir/file.rs", "", cx);
+        });
+
+        // Insert a new file
+        let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
+        fs.as_fake()
+            .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
+            .await;
+
+        // Put the path to the second file into the currently open buffer
+        cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
+
+        // Go to file2.rs
+        cx.simulate_keystrokes("g f");
+
+        // We now have two items
+        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
+        cx.workspace(|workspace, cx| {
+            assert_active_item(workspace, "/root/dir/file2.rs", "This is file2.rs", cx);
+        });
+    }
 }

crates/workspace/src/workspace.rs 🔗

@@ -55,7 +55,9 @@ pub use persistence::{
     WorkspaceDb, DB as WORKSPACE_DB,
 };
 use postage::stream::Stream;
-use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{
+    DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
+};
 use serde::Deserialize;
 use session::AppSession;
 use settings::Settings;
@@ -2015,6 +2017,17 @@ impl Workspace {
         })
     }
 
+    pub fn open_resolved_path(
+        &mut self,
+        path: ResolvedPath,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<anyhow::Result<Box<dyn ItemHandle>>> {
+        match path {
+            ResolvedPath::ProjectPath(project_path) => self.open_path(project_path, None, true, cx),
+            ResolvedPath::AbsPath(path) => self.open_abs_path(path, false, cx),
+        }
+    }
+
     fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext<Self>) {
         let project = self.project.read(cx);
         if project.is_remote() && project.dev_server_project_id().is_none() {