译:Zed 解码:为什么不直接嵌入 Neovim?
译:Zed 解码:为什么不直接嵌入 Neovim?
原文:https://zed.dev/blog/zed-decoded-vim
作者:Thorsten Ball, Conrad Irwin
译者:ChatGPT 4o
发布时间:06/13/24
Zed 解码:为什么不直接嵌入 Neovim?
通常,当我告诉别人我已经转用 Zed 作为主要编辑器后,他们的第一个问题是:你不想念 Vim 吗?然后我告诉他们:Zed 有 Vim 模式。如果没有这个模式,我认为我不会,也不可能切换。
然后,令人惊讶的是,后续问题经常是这样的:Vim 模式?你知道 Neovim 是可嵌入的吗?为什么 Zed 不直接嵌入 Neovim 呢?
所以在这个 Zed 解码篇中,让我们深入探讨 Zed 的 Vim 模式,并找到这些问题的答案。
伴侣视频: 为什么不直接嵌入 Neovim?
这篇文章配有一小时的伴侣视频,Thorsten 与 Conrad 进行了对话,后者在过去几个月里大力改进了 Zed 的 Vim 模式。Thorsten 和 Conrad 一起探讨了 Zed 的 Vim 模式,实施它的难点,为什么我们不嵌入 Neovim 以及我们如何使用 Neovim。
观看视频:点击这里
Zed 的 Vim 模式
首先:Zed 有 Vim 模式。你可以通过在 Zed 设置中添加以下内容来启用它:
{ "vim_mode": true}
一旦你添加并保存设置,你会看到你的光标变成一个块,这意味着你已经准备好探索 Zed 的 Vim 模式了:
h
,j
,k
,l
已准备就绪。- 你可以使用
w
,W
,e
,E
,b
,B
,{
,}
等移动命令。 - 还有
f
和t
以及;
和,
。 - 使用
v
,V
和ctrl-v
进入不同的可视模式。 - 有许多
g
命令,如gg
,gn
,gx
,gt
。
但 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 并保持一致。
好消息是,它正在不断改进。基本标记支持上个月已添加,包围功能前一个月已加入,gn
和 cgn
两个月前也合并了。仅 Conrad 一人就在过去两个月中将超过 20 个 PR 合并到 Zed 的 vim
crate。
哇,见证这些改进并参与其中真是令人开眼界。
操作符一个一个地实现,计数一个一个地添加
作为一个长期 Vim 用户,我已经对 Vim 了解颇多,作为一个新 Zed 开发者在其 Vim 模式上工作时,我半预料到需要实现一些神秘的 Vim 操作符和移动命令。我知道 Vim 里面有很多东西,但仍然被“哦,天啊,Vim 里面真的有很多东西”惊到——操作符、移动命令、修饰符和它们的组合——以及许多我以为很少用的功能实际上被广泛使用。
你知道吗,例如 gs
在 Vim 中代表“goto sleep”,让 Vim 休眠 N 秒?是的,当我们天真地以为 gs
还没被用作快捷键时,这在我们的问题跟踪器中出现了。
或者,也许你已经知道 z.
类似于 zz
,将当前行居中。但你知道它接受一个计数吗?没错:你可以使用 5z.
将第 5 行居中。我不知道这一点。
你肯定知道 I
,这个命令将 Vim 带入插入模式,可能是新 Vim 用户在 :q
之后学到的第二个命令。但你知道 I
也接受一个计数 吗?你可以使用 5ifoobar<esc>
插入 foobar
5 次。同样,a
也接受计数。
谈到计数:你是否曾坐下来思考过 5dj
和 d5j
之间的区别?我们在伴侣视频中讨论了这个问题,而 Conrad 在为更多操作符实现计数时做的不只是思考。
或者,让我问你:你会怀疑有人在日常 Vim 工作流程中使用 r
和 R
——都触发替换模式——吗?更不用说这些 Vim 用户会愿意为实现 r
和 R
支付 $500。我当然不知道。我知道 R
,但我以为,当然,没有人真正使用它。
或者 .
命令,每个 Vim 用户的宠儿。一个简单的命令,一个单独的 .
,做一个简单的事情,对吧?它只是重复你上次所做的。然而,这并不完全是这样:它只重复最后一次对缓冲区的更改,不包括导航。但它也不会重复一个改变了缓冲区的完成动作。例如,当这样说时,这听起来很明显——“是的,嗯哼,那就是 .
所做的,我知道”——但当你试图构建一个通用的 .
命令时,这些细微的差别很容易让你踩到雷。
不同的基础
为什么我要告诉你这些令人惊讶的操作符和组合?分享一个认识:当你试图构建一个尽可能完整的 Vim 模式并不断碰到这些细微差别时,你会意识到 Vim 和 Zed 建立在不同的基础上。
例如,Vim 在缓冲区中定位字符。而 Zed 则定位字符之间的插槽。
这是 abc
中的光标位于 b
上(Vim)与位于 a
和 b
之间(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 博客文章中的说法:CRDTs,Rope,SumTree,文本模型——这是 Zed 的 DNA。Zed 是基于“你不能仅仅在上面添加协作功能”的认识构建的,这需要从头开始内建。
如果我们将 Neovim 放入 Zed,我们必须在启用 Vim 模式时抛弃这种 DNA——这是我们不愿意做的——或者将 DNA 移植到 Neovim。(想象一下有人在做基因剪接,额头上汗水涔涔的动画。)这意味着,我们必须做很多事情两次:一次在 Zed 中,一次在嵌入的 Neovim 中。构建 CRDTs 两次,构建可以同时编辑多缓冲区的多个人两次,等等。
一次构建这些东西已经很难。要做很多工作并且难以做好。要在两个不同的代码库中构建两次……至少难度是两倍。
所以,这就是为什么我们不直接嵌入 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 生成 "golden files",并根据这些文件检查 Zed 的 Vim 模式在相同按键下生成的内容。
相当整洁,对吧?