← 返回博客

为什么不直接嵌入 Neovim?

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

然后,出乎意料的是,经常会有后续问题,听起来像这样:Vim 模式?你知道 Neovim 可以嵌入吗?为什么 Zed 不直接嵌入 Neovim?

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

伴随视频

为什么不直接嵌入 Neovim?

这篇文章附带一个 1 小时的配套视频,其中 Thorsten 与 Conrad 对话,Conrad 在过去几个月里对 Zed 的 Vim 模式进行了大量工作,并取得了巨大的改进。Thorsten 和 Conrad 一起探索了 Zed 的 Vim 模式,实现它的难点,为什么我们不嵌入 Neovim 以及我们如何使用 Neovim。

在此观看视频 →

Why not just embed 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
  • 许多 Vim 以 ctrl-w 开头的“窗口管理”键绑定在 Zed 的 Vim 模式中都有对应的功能
  • 一些更高级的 Vim 功能也有效。尝试使用 * 搜索光标下的单词,使用 cgn 更改下一个匹配项,然后使用 . 重复。
  • 缓冲区局部标记(从 'a'z)和一些内置标记('<'>'[']'{'}^)都有效。
  • 对命名寄存器的基本支持刚刚合并到我们的 main 分支,并将包含在下一个 Zed 预览版中。

Zed 的 Vim 模式中有很多内容。Vim 模式的官方文档页面为您提供了可能的功能和可配置内容的良好概述。您还可以阅读 默认 Vim 键绑定,以了解支持哪些移动和操作符。

但它尚未完成。至少现在还没有。一些重要的功能缺失,例如寄存器和宏,还有许多操作符和移动命令需要添加。我们的跳转列表和更改列表的版本也需要一些调整,以使其更像 Vim 并保持一致。

好消息是它一直在稳步改进。上个月增加了对标记的基本支持环绕命令在前一个月落地gncgn 也于两个月前合并。Conrad 一个人在过去两个月里就将超过 20 个 PR 合并到 Zed 的 vim crate 中

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

操作符逐个,计数逐个

作为一名长期使用 Vim 的用户,我对 Vim 已经非常了解;作为一名从事 Vim 模式开发的 Zed 新开发者,我半预期会遇到需要实现的神秘 Vim 操作符和移动命令。我知道 Vim 中有*很多东西*,但仍然对“哇,Vim 中真的有很多东西”感到惊讶——操作符、移动命令、修饰符以及它们的各种组合——以及许多我本以为很少使用的功能却被广泛使用。

例如,您知道 Vim 中的 gs 代表“goto sleep”,并使 Vim 暂停 N 秒吗?是的,这在我们的问题追踪器中出现了,当时我们天真地以为 gs 还没有被占用作为键绑定。

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

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

说到计数:你有没有坐下来思考过 5djd5j 之间的区别?我们在配套视频中做了,Conrad 在为更多操作符实现计数时不仅仅是思考。

或者,让我问你这个问题:你是否会怀疑有人在日常 Vim 工作流中使用 rR——两者都触发替换模式——?更不用说这些 Vim 用户会 言出必行,为实现 rR 的人支付 500 美元?我当然没有。我的意思是,我知道 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渲染管道自定义异步 Rust 运行时——正是使 Zed 成为 *Zed* 的原因:一个高性能、协作的文本编辑器。或者,用 CRDT 博客文章中的话来说:CRDTs、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,但我们*在测试中*使用了它。

配套视频中,康拉德详细解释了它是如何工作的,所以这里是简短版本。

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 模式在相同击键下产生的结果。

很酷,对吧?


正在寻找更好的编辑器吗?

您今天就可以在 macOS、Windows 或 Linux 上试用 Zed。立即下载


我们正在招聘!

如果您对我们博客中涵盖的主题充满热情,请考虑加入我们的团队,帮助我们实现软件开发的未来。