← 返回博客

为什么不直接嵌入 Neovim?

2024 年 6 月 13 日


通常,当我告诉人们我已将 Zed 作为我的主要编辑器切换使用时,在我使用了大约 15 年的 Vim 之后,他们问的第一个问题是:你不怀念 Vim 吗?然后我告诉他们:Zed 有一个 Vim 模式。如果不是这样,我想我不会或不可能切换。

然后,令人惊讶的是,经常有后续问题听起来像这样:Vim 模式?您知道 Neovim 是可嵌入的吗?为什么 Zed 不直接嵌入 Neovim?

因此,在本期 Zed 解码中,让我们深入研究 Zed 的 Vim 模式,并找到这些问题的答案。

配套视频

为什么不直接嵌入 Neovim?

这篇文章附带一个 1 小时的配套视频,其中 Thorsten 与 Conrad 交谈,Conrad 在过去几个月里在 Zed 的 Vim 模式上做了大量工作,并极大地改进了它。 Thorsten 和 Conrad 一起探索了 Zed 的 Vim 模式、实现它的棘手之处、我们为什么不嵌入 Neovim 以及我们如何使用 Neovim。

在此处观看视频 →

Zed 的 Vim 模式

首先:Zed 具有 Vim 模式。您可以通过将以下内容添加到您的 Zed 设置来启用它

{
  "vim_mode": true
}

添加并保存设置后,您会看到光标变为块状,这意味着您已准备好探索 Zed 的 Vim 模式

  • hjkl 已经准备就绪。
  • 您可以使用类似 wWeEbB{} 的移动。
  • 还有 ft;,
  • 使用 vVctrl-v 进入不同的可视模式。
  • 有很多 g 命令,例如 gggngxgt

但是 Zed 的 Vim 模式不仅仅支持标准的移动和操作符

  • vim-surround 的基本部分已经可以工作:您可以使用 cs"' 将周围的 " 更改为 ',或者使用 ds[ 删除周围的 [。 甚至 vim-surround 的功夫怪物组合 ysiw" 也可以工作。
  • 可以使用法线模式下的 gcc 和可视化模式下的 gc 来切换代码注释。
  • 许多以 ctrl-w 开头的 Vim 的“窗口管理”键绑定在 Zed 的 Vim 模式中都有等效项。
  • 一些更高级的 Vim 功能也可以工作。 尝试使用 "* 搜索光标下的单词,使用 cgn 更改下一个出现的位置,然后使用 . 重复。
  • 缓冲区本地标记('a'z)和一些内置标记('<, '>, '[, '], '{, '}^)工作。
  • 对命名寄存器的基本支持刚刚登陆我们的 main 分支,并且将在下一个 Zed 的预览版发布中。

Zed 的 Vim 模式中包含很多内容。关于 Vim 模式的官方文档页面让您可以很好地了解什么是可能的,以及可以配置什么。您还可以通读默认的 Vim 键绑定,以查看支持哪些移动和操作符。

但它还不完整。 无论如何,还不是。 缺少一些重要的东西,例如寄存器和宏,并且还有许多操作符和移动需要添加。 我们的跳转列表和更改列表的版本也需要进行一些调整,以使其更像 Vim 且更一致。

好消息是,Vim 模式一直在稳步改进。上个月添加了对标记的基本支持在那之前的一个月实现了 surroundsgncgn 也在两个月前合并了。仅 Conrad 就在过去两个月内向 Zed 的 vim crate 提交了 20 多个 PR。

亲眼见证这些改进并参与其中,真是令人大开眼界。

逐个运算符,逐个计数

作为一名资深的 Vim 用户,我已经对 Vim 了解很多。作为一名新的 Zed 开发者,我致力于改进 Vim 模式,我原本以为会有一些晦涩难懂的 Vim 运算符和动作需要实现。我知道 Vim 中有很多东西,但仍然惊讶于 *Vim 中竟然有这么多东西* —— 运算符、动作、修饰符以及它们的各种组合 —— 并且很多我认为很少使用的功能实际上被广泛使用。

你知道吗,例如,Vim 中的 gs 代表 "goto sleep",它会让 Vim 进入睡眠状态 N 秒? 是的,这出现在我们的问题跟踪器中,当时我们天真地认为 gs 还没有被用作快捷键。

或者,也许你已经知道 z.,它类似于 zz,并将当前行居中。但是你知道它可以接受一个计数吗? 是的:你可以执行 5z.,它将第 5 行居中。我以前不知道。

你肯定知道 i,这个命令让 Vim 进入插入模式,可能是新的 Vim 用户在 :q 之后学习的第二件事。 但是你知道 i *也可以接受一个计数* 吗? 你可以使用 5ifoobar<esc> 来插入 5 次 foobar。而且,是的,a 也可以接受一个计数。

说到计数:你有没有认真思考过 5djd5j 之间的区别? 我们研究过,在配套视频中,并且 Conrad 在为更多运算符实现计数时做了更多的思考。

或者,让我问你这个问题:你会怀疑有人在日常 Vim 工作流程中使用 rR 吗? —— 两者都会触发替换模式 —— 更不用说这些 Vim 用户会 付出真金白银,悬赏 500 美元给任何实现 rR 的人。 我当然不会。 我的意思是,我知道 R,但我认为,肯定没有人 *真正* 使用它。

或者 . 命令,这是每个人都喜欢的 Vim 命令。 一个简单的命令,一个单独的 .,它做一件简单的事情,对吗? 它只是重复你上次做的事情。 除非它没有完全捕捉到:它只重复上次 *对缓冲区的更改*,而不是导航。但它也不会重复例如改变了缓冲区的完成动作。 这样说听起来很明显 —— "是的,嗯哼,这就是 . 的作用,我知道" —— 但当你试图构建一个通用的 . 命令时,真的很容易在这些细微的细节上碰壁。

不同的基础

我为什么要告诉你这些令人惊讶的运算符和组合? 为了分享一个认识:当你在尝试构建一个尽可能完整的 Vim 模式并且不断遇到这些微妙之处时,你就会意识到 Vim 和 Zed 建立在不同的基础上。

例如,Vim 寻址缓冲区中的字符。另一方面,Zed 寻址 *字符之间* 的槽位。

这就是 abc 中光标位于 b 上 (Vim) 还是位于 ab 之间 (Zed) 的区别。你可以想象,像这样的一个不变量的连锁反应会变成五个抽象层之上的波浪。

考虑一下两个编辑器如何处理换行符:Vim 区分行尾和行中的最后一个字符。 在实践中,这意味着你可以,例如,使用 v$ 创建一个直到行尾的可视化选择,然后通过点击 l 来额外选择换行符,因此使用 d 进行删除会删除整行,但看起来你好像只选择了第一行。

在非 Vim 模式的 Zed 中,你可以做类似的事情,选择直到行尾。 但是,只要你的光标停留在该行上,该选择就不包括换行符。 一旦你选择换行符,你的光标就会跳到下一行。

在 Zed 的 Vim 模式中,我们尝试尽可能多地解决(或:规避)这些差异,以使 Vim 模式尽可能像 Vim。 引用 我们关于 Vim 模式的文档

Zed 中的 Vim 模式主要是“做你期望的事情”:它主要尝试完全复制 Vim

这不仅是困难而棘手的工作(请参阅 这个问题 以了解涉及哪些边缘情况),而且这种努力也存在限制,存在上限:我们不想抛弃 Zed 的基础。 有很多东西与它们相关联。

这就是为什么它没有嵌入

你看,如果你要将 Neovim 嵌入到 Zed 中,你最终会做同样的事情:你会抛弃 Zed 的基础并用 Neovim 替换它们。

但这些基础 —— 用于表示文本的数据结构CRDTs渲染管线自定义 Async Rust 运行时 —— 才是让 Zed 成为 Zed 的原因:一个高性能、协作式文本编辑器。或者,用 CRDT 博客文章中的话来说:CRDT、Rope、SumTree、文本模型 —— 这就是 Zed 的 DNA。Zed 建立在这样一个认识之上:你不能只是在上面添加协作功能,它需要从头开始构建,内置于其中。

如果我们将 Neovim 放入 Zed,我们就必须在启用 Vim 模式时抛弃这些 DNA —— 这是我们不希望的 —— 或者将 DNA 移植到 Neovim。(想象一下某人正在进行基因剪接的动画,额头上流着大量的汗水。)这意味着,我们将不得不做很多事情两次:一次在 Zed 中,一次在嵌入的 Neovim 中。构建 CRDT 两次,构建多人可以同时编辑的多缓冲区两次,等等。

构建这些东西一次就已经很困难了。 这是一项很难做对的大量工作。 在两个不同的代码库中构建两次……嗯,至少困难两倍。

所以,这就是原因。 这就是为什么我们不只是将 Neovim 嵌入到 Zed 中。 相反,我们在 Zed 内部构建了一个 Vim 模式。 我个人认为,这比仅仅嵌入 Vim 更有趣。

Vim 和 Zed,融合在一起

由于 Zed 的 Vim 模式位于 Zed 的基础之上,因此你得到的是两者的结合。 启用 Vim 模式后,你仍然可以使用非 Vim 模式 Zed 中几乎所有可用的功能。

例如:你可以使用 gl 创建一个额外的光标,该光标位于当前单词的下一个出现位置之上。 或者使用 gL 做同样的事情,但向后。 使用其中一个,然后点击 <esc>,你将留下多个光标,但处于 Vim 普通模式,并且拥有我们可用的每个动作和操作符。

或者,尝试一下:使用 ]x[x 选择一个 Treesitter 语法节点。 这些选择你也可以与多光标结合使用。 因此,如果你选择了一个 Treesitter 节点,请点击 gl,它将在外观相同的节点上创建另一个光标。

我正在做这件事的录像

或者点击 。 这不仅会打开 Zed 命令面板,而且还有常见命令的绑定和快捷方式,例如 :w:E[xplore] 打开项目面板,:te[rm] 打开终端,等等。

g]g[ 在诊断错误之间导航,]c[c 在 git 更改之间导航。gs 在当前文件中打开符号大纲,gS 执行相同的操作,但在整个项目中。g. 打开代码操作。 查看 Vim 模式的 Zed 特定功能,了解你还可以在 Zed 的 Vim 模式中执行哪些操作。 如果缺少某些内容,你始终可以回退到 cmd- 快捷方式或打开命令调色板,找到一个命令,并创建一个自定义绑定。

例如,以下是我在 Zed keymaps.json 中的一些绑定,我在 Vim 模式中使用它

[
  {
    "context": "EmptyPane || SharedScreen || vim_operator == none && !VimWaiting && vim_mode != insert",
    "bindings": {
      ", f b": "tab_switcher::Toggle",
      ", f i": "file_finder::Toggle",
      ", f o": "projects::OpenRecent",
      ", r l": "task::Rerun",
      ", r e": ["task::Rerun", { "reevaluate_context": true }],
      "ctrl-s": "projects::OpenRecent"
    }
  },
  {
    "context": "Editor && VimControl && !VimWaiting && !menu",
    "bindings": {
      "g shift-r": "editor::FindAllReferences",
      "g a": "editor::ToggleCodeActions",
      "g r": "editor::Rename",
      "space w": "workspace::Save",
      ", g b": "editor::ToggleGitBlame"
    }
  }
]

Neovim……终于

现在你已经知道为什么我们不会嵌入 Neovim 以及它可能有哪些优势,让我给你留下一些非常棒的东西,你可以在喝酒时与其他程序员朋友分享:我们确实在 Zed 中使用 Neovim,但我们在测试中使用它。

配套视频中,Conrad 详细解释了它的工作原理,所以这里是简短版本。

Zed 的所有 Vim 模式都包含在一个 crate 中,vim,并且在那一个中,一些测试 看起来像这样

#[gpui::test]
async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
    let mut cx = NeovimBackedTestContext::new(cx).await;
 
    cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
    cx.simulate_shared_keystrokes("v 3 l *").await;
    cx.shared_state().await.assert_eq("a.c. abcd ˇa.c. abcd");
}

这里有趣的部分是 NeovimBackedTestContext::new():这会导致测试运行一个无头的 Neovim 实例,将初始状态发送到它,然后模拟击键。 然后将产生的最终状态保存到 JSON 文件中,并针对状态测试 Zed 的 Vim 实现。

换句话说:我们在测试中使用无头的 Neovim 来生成 “黄金文件”,我们根据这些文件检查 Zed 的 Vim 模式用相同的击键产生了什么。

很棒,对吧?