vim: Add :r[ead] [name] command (#45332)

This adds the following Vim commands: 
- `:r[ead] [name]`
- `:{range}r[ead] [name]`

The most important parts of this feature are outlined
[here](https://vimhelp.org/insert.txt.html#%3Ar).

The only intentional difference between this and Vim is that Vim only
allows `:read` (no filename) for buffers with a file attached. I am
allowing it for all buffers because I think that could be useful.

Release Notes:

- vim: Added the [`:r[ead] [name]` Vim
command](https://vimhelp.org/insert.txt.html#:read)

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
AidanV
2025-12-19 10:31:16 -05:00
committed by GitHub
parent b603372f44
commit 8001877df2

View File

@@ -230,6 +230,14 @@ struct VimEdit {
pub filename: String,
}
/// Pastes the specified file's contents.
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
struct VimRead {
pub range: Option<CommandRange>,
pub filename: String,
}
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
struct VimNorm {
@@ -643,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
});
});
Vim::action(editor, cx, |vim, action: &VimRead, window, cx| {
vim.update_editor(cx, |vim, editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let end = if let Some(range) = action.range.clone() {
let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err()
else {
return;
};
match &range.start {
// inserting text above the first line uses the command ":0r {name}"
Position::Line { row: 0, offset: 0 } if range.end.is_none() => {
snapshot.clip_point(Point::new(0, 0), Bias::Right)
}
_ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right),
}
} else {
let end_row = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx))
.range()
.end
.row;
snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right)
};
let is_end_of_file = end == snapshot.max_point();
let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end);
let mut text = if is_end_of_file {
String::from('\n')
} else {
String::new()
};
let mut task = None;
if action.filename.is_empty() {
text.push_str(
&editor
.buffer()
.read(cx)
.as_singleton()
.map(|buffer| buffer.read(cx).text())
.unwrap_or_default(),
);
} else {
if let Some(project) = editor.project().cloned() {
project.update(cx, |project, cx| {
let Some(worktree) = project.visible_worktrees(cx).next() else {
return;
};
let path_style = worktree.read(cx).path_style();
let Some(path) =
RelPath::new(Path::new(&action.filename), path_style).log_err()
else {
return;
};
task =
Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx)));
});
} else {
return;
}
};
cx.spawn_in(window, async move |editor, cx| {
if let Some(task) = task {
text.push_str(
&task
.await
.log_err()
.map(|loaded_file| loaded_file.text)
.unwrap_or_default(),
);
}
if !text.is_empty() && !is_end_of_file {
text.push('\n');
}
let _ = editor.update_in(cx, |editor, window, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.edit([(edit_range.clone(), text)], cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor.change_selections(Default::default(), window, cx, |s| {
let point = if is_end_of_file {
Point::new(
edit_range.start.to_point(&snapshot).row.saturating_add(1),
0,
)
} else {
Point::new(edit_range.start.to_point(&snapshot).row, 0)
};
s.select_ranges([point..point]);
})
});
});
})
.detach();
});
});
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
let keystrokes = action
.command
@@ -1338,6 +1447,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
.bang(editor::actions::ReloadFile)
.filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
VimCommand::new(
("r", "ead"),
VimRead {
range: None,
filename: "".into(),
},
)
.filename(|_, filename| {
Some(
VimRead {
range: None,
filename,
}
.boxed_clone(),
)
})
.range(|action, range| {
let mut action: VimRead = action.as_any().downcast_ref::<VimRead>().unwrap().clone();
action.range.replace(range.clone());
Some(Box::new(action))
}),
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
Some(
VimSplit {
@@ -2575,6 +2705,76 @@ mod test {
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
}
#[gpui::test]
async fn test_command_read(cx: &mut TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
let path = Path::new(path!("/root/dir/other.rs"));
fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
cx.workspace(|workspace, _, cx| {
assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
});
// File without trailing newline
cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
cx.set_state("one\nˇtwo\nthree", Mode::Normal);
cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
// Empty filename
cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
cx.simulate_keystrokes(": r");
cx.simulate_keystrokes("enter");
cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
// File with trailing newline
fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
// Empty file
fs.as_fake().insert_file(path, "".into()).await;
cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
cx.simulate_keystrokes("enter");
cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
}
#[gpui::test]
async fn test_command_quit(cx: &mut TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;