feat: follow Markdown file links and heading fragments
This commit is contained in:
+448
-2
@@ -291,6 +291,7 @@ enum Action {
|
||||
Palette,
|
||||
FilePicker,
|
||||
FileExplorer,
|
||||
FollowLink,
|
||||
LspDefinition,
|
||||
LspHover,
|
||||
LspDiagnostics,
|
||||
@@ -471,6 +472,7 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
|
||||
Action::FileExplorer,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
),
|
||||
("core.follow_link", Action::FollowLink, &[Mode::Normal]),
|
||||
(
|
||||
"core.lsp.definition",
|
||||
Action::LspDefinition,
|
||||
@@ -887,6 +889,7 @@ pub struct Editor {
|
||||
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
|
||||
leader_suffix: Vec<Input>,
|
||||
leader_display: String,
|
||||
g_pending: bool,
|
||||
palette: Option<Palette>,
|
||||
hover: Option<Hover>,
|
||||
startup_diagnostic: Option<StartupDiagnostic>,
|
||||
@@ -946,6 +949,7 @@ impl Editor {
|
||||
leader_bindings: HashMap::new(),
|
||||
leader_suffix: Vec::new(),
|
||||
leader_display: String::new(),
|
||||
g_pending: false,
|
||||
palette: None,
|
||||
hover: None,
|
||||
startup_diagnostic: None,
|
||||
@@ -975,6 +979,7 @@ impl Editor {
|
||||
"bprev",
|
||||
"bdelete",
|
||||
"bdelete!",
|
||||
"follow_link",
|
||||
"lsp.definition",
|
||||
"lsp.hover",
|
||||
"lsp.diagnostics",
|
||||
@@ -1287,12 +1292,18 @@ impl Editor {
|
||||
|
||||
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
|
||||
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
|
||||
self.cancel_leader();
|
||||
self.clear_hover();
|
||||
if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) {
|
||||
return Err("Navigation path must be nonempty and contain no controls".into());
|
||||
}
|
||||
self.edit_buffer(path, false)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.place_navigation(position);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn place_navigation(&mut self, position: TextPosition) {
|
||||
self.park_source();
|
||||
self.active.mode = Mode::Normal;
|
||||
let row = position.row.min(self.active.lines.len() - 1);
|
||||
@@ -1304,6 +1315,72 @@ impl Editor {
|
||||
self.active.search_match = None;
|
||||
self.active.visual_motions = None;
|
||||
self.normalize_targets();
|
||||
}
|
||||
|
||||
fn follow_link(&mut self) -> Result<(), String> {
|
||||
if self
|
||||
.active
|
||||
.path
|
||||
.as_deref()
|
||||
.is_some_and(|path| !crate::syntax::is_markdown_path(path))
|
||||
{
|
||||
return Err("follow_link requires a Markdown document".into());
|
||||
}
|
||||
let source = self.active.lines.join("\n");
|
||||
let cursor = self.cursor();
|
||||
let target = crate::markdown::link_at(
|
||||
&source,
|
||||
TextPosition {
|
||||
row: cursor.row,
|
||||
column: cursor.column,
|
||||
},
|
||||
)?;
|
||||
let Some(path) = target.path else {
|
||||
let row = match target.fragment {
|
||||
Some(fragment) => crate::markdown::heading_row(&source, &fragment)?,
|
||||
None => 0,
|
||||
};
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
return Ok(());
|
||||
};
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
let source_path = self
|
||||
.active
|
||||
.identity
|
||||
.as_deref()
|
||||
.ok_or("Relative links require a named Markdown document")?;
|
||||
source_path
|
||||
.parent()
|
||||
.ok_or("Markdown document has no parent directory")?
|
||||
.join(path)
|
||||
};
|
||||
if path.to_string_lossy().chars().any(char::is_control) {
|
||||
return Err("Link path cannot contain control characters".into());
|
||||
}
|
||||
let identity = path_identity(&path).map_err(|error| error.to_string())?;
|
||||
let heading_row = |buffer: &Buffer| {
|
||||
target.fragment.as_deref().map_or(Ok(0), |fragment| {
|
||||
crate::markdown::heading_row(&buffer.lines.join("\n"), fragment)
|
||||
})
|
||||
};
|
||||
let loaded = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| buffer.identity.as_ref() == Some(&identity));
|
||||
let row = if let Some(buffer) = loaded {
|
||||
let row = heading_row(buffer)?;
|
||||
let id = buffer.id;
|
||||
self.switch_buffer(id);
|
||||
row
|
||||
} else {
|
||||
let buffer = Buffer::open(self.next_buffer_id, Some(path), Some(identity), false)
|
||||
.map_err(|error| format!("Cannot open link target: {error}"))?;
|
||||
let row = heading_row(&buffer)?;
|
||||
self.add_buffer(buffer);
|
||||
row
|
||||
};
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1497,13 +1574,19 @@ impl Editor {
|
||||
(!self.leader_display.is_empty()).then_some(self.leader_display.as_str())
|
||||
}
|
||||
|
||||
/// Whether the built-in Normal `gf` prefix is awaiting its second key.
|
||||
pub fn g_pending(&self) -> bool {
|
||||
self.g_pending
|
||||
}
|
||||
|
||||
fn clear_leader(&mut self) {
|
||||
self.g_pending = false;
|
||||
self.leader_suffix.clear();
|
||||
self.leader_display.clear();
|
||||
}
|
||||
|
||||
fn cancel_leader(&mut self) {
|
||||
if self.leader_pending().is_some() {
|
||||
if self.leader_pending().is_some() || self.g_pending {
|
||||
self.count = None;
|
||||
self.clear_leader();
|
||||
}
|
||||
@@ -1892,6 +1975,16 @@ impl Editor {
|
||||
})
|
||||
.map(|(input, _)| display_key(input))
|
||||
.collect();
|
||||
if action == Some(Action::FollowLink)
|
||||
&& self.active.mode == Mode::Normal
|
||||
&& self.leader != Input::Char('g')
|
||||
&& !self
|
||||
.bindings
|
||||
.get(&Mode::Normal)
|
||||
.is_some_and(|keys| keys.contains_key(&Input::Char('g')))
|
||||
{
|
||||
bindings.push("gf".into());
|
||||
}
|
||||
if !self
|
||||
.bindings
|
||||
.get(&self.active.mode)
|
||||
@@ -2108,6 +2201,24 @@ impl Editor {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if std::mem::take(&mut self.g_pending) {
|
||||
if self.active.mode == Mode::Normal
|
||||
&& self.startup_diagnostic.is_none()
|
||||
&& self.hover().is_none()
|
||||
&& self.palette.is_none()
|
||||
&& !self
|
||||
.file_picker
|
||||
.as_ref()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
&& input == Input::Char('f')
|
||||
{
|
||||
return self.act(Action::FollowLink);
|
||||
}
|
||||
self.count = None;
|
||||
if input == Input::Escape {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(diagnostic) = &mut self.startup_diagnostic {
|
||||
match input {
|
||||
Input::Enter | Input::Escape => self.startup_diagnostic = None,
|
||||
@@ -2212,6 +2323,11 @@ impl Editor {
|
||||
append_key(&mut self.leader_display, &input);
|
||||
return None;
|
||||
}
|
||||
if self.active.mode == Mode::Normal && input == Input::Char('g') {
|
||||
self.count = None;
|
||||
self.g_pending = true;
|
||||
return None;
|
||||
}
|
||||
self.count = None;
|
||||
match (self.active.mode, input) {
|
||||
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
|
||||
@@ -2273,6 +2389,11 @@ impl Editor {
|
||||
}
|
||||
Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
|
||||
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
|
||||
Action::FollowLink => {
|
||||
if let Err(error) = self.follow_link() {
|
||||
self.set_message(error);
|
||||
}
|
||||
}
|
||||
Action::LspDefinition => return self.execute("lsp.definition"),
|
||||
Action::LspHover => return self.execute("lsp.hover"),
|
||||
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
|
||||
@@ -2562,9 +2683,13 @@ impl Editor {
|
||||
Some(identity),
|
||||
allow_missing,
|
||||
)?;
|
||||
self.add_buffer(buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_buffer(&mut self, buffer: Buffer) {
|
||||
self.next_buffer_id += 1;
|
||||
self.activate(buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_buffer(&mut self, id: usize, force: bool) {
|
||||
@@ -2679,6 +2804,15 @@ impl Editor {
|
||||
if self.buffer_command(name, args) {
|
||||
return None;
|
||||
}
|
||||
// Link preflight must precede Ex cleanup: failure retains carets and text.
|
||||
if name == "follow_link" {
|
||||
if !args.is_empty() {
|
||||
self.set_message("The follow_link command takes no arguments");
|
||||
} else if let Err(error) = self.follow_link() {
|
||||
self.set_message(error);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if self.active.mode == Mode::Insert {
|
||||
self.finish_insert();
|
||||
}
|
||||
@@ -4418,6 +4552,318 @@ mod tests {
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() {
|
||||
let directory = Directory::new();
|
||||
let nested = directory.path("notes");
|
||||
fs::create_dir(&nested).unwrap();
|
||||
let source = nested.join("source.md");
|
||||
let target = directory.path("Target Name.MD");
|
||||
fs::write(&source, "[go](../Target%20Name.MD#target-heading)").unwrap();
|
||||
fs::write(&target, "intro\n\n# Target heading\nbody").unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
assert_ne!(std::env::current_dir().unwrap(), nested);
|
||||
editor.handle(Input::Char('g'));
|
||||
assert!(editor.g_pending());
|
||||
editor.handle(Input::Char('f'));
|
||||
assert!(!editor.g_pending());
|
||||
assert_eq!(editor.buffer_id(), 2);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
assert_eq!(editor.context().text, "intro\n\n# Target heading\nbody");
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_reference_and_duplicate_heading_use_native_ex_command() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.path("source.md");
|
||||
let target = directory.path("target.md");
|
||||
fs::write(
|
||||
&source,
|
||||
"[go][section]\n\n[section]: target.md#hello-world-1",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&target,
|
||||
"# Hello [world](ignored.md)\n\nHello *world*\n=============\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
type_text(&mut editor, ":follow_link\n");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
assert_eq!(editor.lines()[0], "# Hello [world](ignored.md)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_reuses_dirty_target_even_after_disk_file_disappears() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.path("source.md");
|
||||
let target = directory.path("target.md");
|
||||
fs::write(&source, "[go](target.md#dirty-heading)").unwrap();
|
||||
fs::write(&target, "# Disk heading").unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
editor.execute(&format!("edit {}", target.display()));
|
||||
let target_id = editor.buffer_id();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("unsaved\n\n# Dirty heading".into())])
|
||||
.unwrap();
|
||||
let revision = editor.text_revision();
|
||||
editor.execute("buffer 1");
|
||||
fs::remove_file(&target).unwrap();
|
||||
editor
|
||||
.bind("normal", "z", Some("core.follow_link".into()))
|
||||
.unwrap();
|
||||
assert_eq!(editor.handle(Input::Char('z')), None);
|
||||
assert_eq!(editor.buffer_id(), target_id);
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
assert_eq!(editor.context().text, "unsaved\n\n# Dirty heading");
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_failures_leave_source_carets_selections_text_and_buffer_ids_untouched() {
|
||||
let directory = Directory::new();
|
||||
fs::write(directory.path("target.md"), "# Existing").unwrap();
|
||||
for (filename, text, command) in [
|
||||
("source.md", "[x](missing.md)", "follow_link"),
|
||||
("source.md", "[x](target.md#missing)", "follow_link"),
|
||||
("source.md", "[x](#missing)", "follow_link"),
|
||||
("source.md", "[missing][undefined]", "follow_link"),
|
||||
("source.md", "[x](https://example.com)", "follow_link"),
|
||||
("source.md", "not a link", "follow_link"),
|
||||
("source.txt", "[x](target.md)", "follow_link"),
|
||||
("source.md", "[x](target.md)", "follow_link extra"),
|
||||
] {
|
||||
let source = directory.path(filename);
|
||||
fs::write(&source, text).unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
type_text(&mut editor, "vl");
|
||||
let carets = editor.carets();
|
||||
let selections = editor.selections();
|
||||
let revision = editor.text_revision();
|
||||
assert_eq!(editor.execute(command), None);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert!(!editor.message().is_empty());
|
||||
if command == "follow_link" {
|
||||
editor.handle(Input::Escape);
|
||||
let carets = editor.carets();
|
||||
type_text(&mut editor, "gf");
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert_eq!(editor.context().text, text);
|
||||
}
|
||||
editor.execute(&format!("edit {}", directory.path("target.md").display()));
|
||||
assert_eq!(editor.buffer_id(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_same_document_fragments_work_without_named_buffer() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec\n\n# Hello **world**\n".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
let revision = editor.text_revision();
|
||||
editor
|
||||
.bind("normal", "leader f", Some("core.follow_link".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, " f");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert_eq!(editor.path(), None);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
"[go](#hello-world)\n\n# Hello **world**\n"
|
||||
);
|
||||
editor.handle(Input::Home);
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Up);
|
||||
editor
|
||||
.apply(vec".into())])
|
||||
.unwrap();
|
||||
editor.execute("follow_link");
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
assert!(editor.message().contains("named"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_explicit_same_file_path_reuses_current_dirty_buffer() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.path("source.md");
|
||||
fs::write(&source, "# Old disk heading").unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
let text = "[go](source.md#dirty-heading)\n\n# Dirty heading";
|
||||
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
|
||||
let revision = editor.text_revision();
|
||||
type_text(&mut editor, "gf");
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.buffer_count(), 1);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert!(editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_uses_only_primary_caret_once() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.path("source.md");
|
||||
let target = directory.path("target.md");
|
||||
fs::write(&source, "[bad](missing.md)\n[go](target.md)").unwrap();
|
||||
fs::write(&target, "# Target").unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
assert_eq!(editor.carets().len(), 2);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||
type_text(&mut editor, "gf");
|
||||
assert_eq!(editor.buffer_id(), 2);
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
assert_eq!(editor.carets(), vec![Cursor::default()]);
|
||||
assert_eq!(editor.lines(), ["# Target"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_link_accepts_nonmarkdown_targets_without_fragments() {
|
||||
let directory = Directory::new();
|
||||
let source = directory.path("source.md");
|
||||
let target = directory.path("main.rs");
|
||||
fs::write(&source, "[code](main.rs)").unwrap();
|
||||
fs::write(&target, "fn main() {}").unwrap();
|
||||
let mut editor = Editor::open(Some(source), vec![]).unwrap();
|
||||
type_text(&mut editor, "9gf");
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
assert_eq!(editor.count(), None);
|
||||
assert_eq!(editor.buffer_count(), 2);
|
||||
assert_eq!(editor.lines(), ["fn main() {}"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a\nb\nc\nd".into())])
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert_eq!(
|
||||
palette_entry(&editor, "core.follow_link").bindings,
|
||||
vec!["gf"]
|
||||
);
|
||||
assert!(palette_entry(&editor, "core.follow_link").enabled);
|
||||
editor.handle(Input::Escape);
|
||||
editor
|
||||
.bind("normal", "g", Some("core.down".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
|
||||
assert_eq!(editor.count(), None);
|
||||
assert!(!editor.g_pending());
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(
|
||||
palette_entry(&editor, "core.follow_link")
|
||||
.bindings
|
||||
.is_empty()
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
editor.bind("normal", "g", None).unwrap();
|
||||
type_text(&mut editor, "g");
|
||||
assert!(!editor.g_pending());
|
||||
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||||
editor.bind("normal", "g", Some("greet".into())).unwrap();
|
||||
type_text(&mut editor, "3");
|
||||
assert_eq!(
|
||||
editor.handle(Input::Char('g')),
|
||||
Some(Invocation {
|
||||
plugin: "fixture".into(),
|
||||
command: "greet".into(),
|
||||
args: String::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(editor.count(), None);
|
||||
assert!(!editor.g_pending());
|
||||
for mode in ["insert", "visual", "command", "search"] {
|
||||
assert!(
|
||||
editor
|
||||
.bind(mode, "z", Some("core.follow_link".into()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert!(!palette_entry(&editor, "core.follow_link").enabled);
|
||||
assert!(
|
||||
palette_entry(&editor, "core.follow_link")
|
||||
.bindings
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_g_prefix_cancels_on_escape_intervening_input_commands_and_lifecycle() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("a\nb\nc".into())])
|
||||
.unwrap();
|
||||
type_text(&mut editor, "4g");
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.g_pending());
|
||||
assert_eq!(editor.count(), None);
|
||||
type_text(&mut editor, "4gj");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
|
||||
assert!(!editor.g_pending());
|
||||
type_text(&mut editor, "gi");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert!(!editor.g_pending());
|
||||
editor.handle(Input::Char('f'));
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "a\nfb\nc");
|
||||
for command in ["plugins", "bnext", "bprev", "buffer 1", "bdelete!"] {
|
||||
editor.handle(Input::Char('g'));
|
||||
assert!(editor.g_pending());
|
||||
editor.execute(command);
|
||||
assert!(!editor.g_pending());
|
||||
}
|
||||
editor.handle(Input::Char('g'));
|
||||
editor.handle(Input::Paste("f".into()));
|
||||
assert!(!editor.g_pending());
|
||||
editor.handle(Input::Char('g'));
|
||||
editor
|
||||
.navigate_to(
|
||||
directory.path("missing.md"),
|
||||
TextPosition { row: 0, column: 0 },
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(!editor.g_pending());
|
||||
editor.handle(Input::Char('g'));
|
||||
editor
|
||||
.bind("normal", "z", Some("core.follow_link".into()))
|
||||
.unwrap();
|
||||
assert!(!editor.g_pending());
|
||||
editor.set_leader("g").unwrap();
|
||||
editor.handle(Input::Char('g'));
|
||||
assert!(!editor.g_pending());
|
||||
assert_eq!(editor.leader_pending(), Some("g"));
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
assert_eq!(
|
||||
palette_entry(&editor, "core.follow_link").bindings,
|
||||
vec!["z"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() {
|
||||
use crate::plugin::SemanticTokenKind;
|
||||
|
||||
Reference in New Issue
Block a user