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
This commit is contained in:
Thorsten Ball 2024-08-22 14:27:11 +02:00 committed by GitHub
parent 1e39d407c2
commit db0c1fd592
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 579 additions and 43 deletions

View File

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

View File

@ -262,6 +262,7 @@ gpui::actions!(
OpenExcerptsSplit,
OpenPermalinkToLine,
OpenUrl,
OpenFile,
Outdent,
PageDown,
PageUp,

View File

@ -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())

View File

@ -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);

View File

@ -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,6 +523,19 @@ pub fn show_link_definition(
})
.ok()
} else if let Some(project) = project {
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))
});
Some((range, vec![HoverLink::File(filename)]))
} else {
// query the LSP for definition info
project
.update(&mut cx, |project, cx| match preferred_kind {
@ -543,14 +557,20 @@ pub fn show_link_definition(
excerpt_id,
origin.range.start,
)?;
let end = snapshot
.anchor_in_excerpt(excerpt_id, origin.range.end)?;
let end = snapshot.anchor_in_excerpt(
excerpt_id,
origin.range.end,
)?;
Some(RangeInEditor::Text(start..end))
})
}),
definition_result.into_iter().map(HoverLink::Text).collect(),
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");
});
}
}

View File

@ -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.

View File

@ -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>,

View File

@ -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);
});
}
}

View File

@ -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() {