From db0c1fd592052da5d9ea8aa6ab1a13a8b5d8b669 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Thu, 22 Aug 2024 14:27:11 +0200 Subject: [PATCH] vim: Add 'gf' command, make files cmd-clickable (#16534) 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 --- 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(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 30b4f588b4..388ba5e8ab 100644 --- a/assets/keymaps/vim.json +++ b/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", diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 1e68f818bd..3fa241676f 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -262,6 +262,7 @@ gpui::actions!( OpenExcerptsSplit, OpenPermalinkToLine, OpenUrl, + OpenFile, Outdent, PageDown, PageUp, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 28e42271b6..4b19ae7e2c 100644 --- a/crates/editor/src/editor.rs +++ b/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) { + 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, @@ -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), + 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::>(); (title, location_tasks, editor.workspace().clone()) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f26765a574..0053a7b567 100644 --- a/crates/editor/src/element.rs +++ b/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); diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index deefae9d8c..58543bb9d4 100644 --- a/crates/editor/src/hover_links.rs +++ b/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, + project: Model, + position: text::Anchor, + cx: &mut AsyncWindowContext, +) -> Option<(Range, 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, 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::() + .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::(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::(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::(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::(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"); + }); + } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 519228a17b..bf300d6808 100644 --- a/crates/language/src/buffer.rs +++ b/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. diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b095639eb2..36fe70572b 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -649,16 +649,17 @@ impl DirectoryLister { }; "~/".to_string() } - pub fn list_directory(&self, query: String, cx: &mut AppContext) -> Task>> { + + pub fn list_directory(&self, path: String, cx: &mut AppContext) -> Task>> { 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, + cx: &mut ModelContext, + ) -> Task> { + // 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, + cx: &mut ModelContext, + ) -> Task> { + 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::>(); + 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, diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 2345b76d91..a481e954ad 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -742,9 +742,15 @@ fn generate_positions(string: &str, query: &str) -> Vec { 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, + ) { + let active_editor = workspace.active_item_as::(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); + }); + } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index a1f975526f..d2be56fa4b 100644 --- a/crates/workspace/src/workspace.rs +++ b/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, + ) -> Task>> { + 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) { let project = self.project.read(cx); if project.is_remote() && project.dev_server_project_id().is_none() {