暗无天日

=============>DarkSun的个人博客

读:space-tree——Emacs 的树形工作区管理器

工作区管理为什么需要树

Charlie Holland 写了一篇博文介绍他开发的 space-tree 工具(见 原文)。这个工具要解决的问题很简单。Emacs 里不缺工作区管理工具,从内置的 tab-bar-mode 到社区维护的 eyebrowseperspective.elpersp-modeactivities.elburlybeframeworkgroups2 ,一抓一大把。但它们有一个共同的盲区,所有工作区都是扁平的。

扁平有什么问题?你有一个编号列表、一行标签或者一个网格,但你没办法在工作区里面再建一个工作区。实际开发中很少有一层就能搞定的任务。假设你在修一个 bug,可能需要测试源码、实现代码、堆栈追踪同时开着。这个 bug 可能关联到一个更大的重构。重构本身是一个父任务,修 bug 只是它的一个子任务。子任务内部可能还有更细的分工(翻阅日志、对比不同版本、查阅文档)。扁平的工作区列表迫使你去「压平」这种天然的分层结构。

作者总结了他个人的五个需求,不一定适用所有人,但能解释他为什么觉得现有工具都不够好用。

  • 任意深度嵌套,不限制宽度 。你可以有 1→1.1→1.1.1 这样的结构,工作区管理器不该限制你的层次数
  • 不需要强制命名 。大部分工作区用完就丢,不值得起名字。强制命名等于增加工作量,工作量大了你就懒得创建新工作区,结果所有窗口又挤回一个空间
  • 不需要强制持久化 。昨天的树和今天的树通常是两码事。持久化是好功能,但管理持久化状态(清理过期条目、决定启动时恢复什么)的成本超过了恢复本身的价值
  • 不做缓冲区作用域 。工作区对作者来说是纯粹的视觉约定,它只管「在这里放什么窗口」。 switch-to-buffer 应该始终能看到所有缓冲区,不需要按工作区切割。如果确实需要任务级别的文件视图,Emacs 已有 project.elconsult-buffer 这些更精准的工具
  • 基于 Emacs 已有窗口原语 。"原语"(primitive)指 Emacs 自带的 window-state-getwindow-state-put ,这两个函数已经能保存和恢复窗口布局了。不需要引入新的"抽象",即其他工具搞的那套 parallel buffer list 或 frame 快照模型——那些都是额外抽象层,直接嵌到 Emacs 已有的窗口机制上就好

认知心理学怎么说

说实话,作者自己也怀疑「树形」这个想法是不是程序员职业病,毕竟天天对着文件系统、抽象语法树、Org-mode 大纲,看什么都像树。查了文献后他发现,认知心理学对层次化组织的支持相当扎实。

工作记忆的上限

George Miller 1956 年的论文提出人的工作记忆大约能 hold 住 7 ± 2 个项目。这个数字已经很小了,但 Nelson Cowan 2001 年的综述进一步收窄,排除复述和组块化的干扰后,真实的工作记忆容量大约只有 3 到 5 个项目。但一次正式的编码会话可能同时打开的缓冲区可远不止 5 个。扁平的工作区列表把管理这些窗口的负担全部压在读者本来就有限的工作记忆上。

组块化是专家绕过上限的方式

William Chase 和 Herbert Simon 1973 年做了一个实验,给国际象棋大师和业余棋手看一个中局棋面 5 秒,然后在空棋盘上复原。大师的成绩远好于业余棋手。但棋子如果是随机摆放的,大师的优势就完全消失了。

差别不在记忆容量,而在于大师比业余棋手更能把有意义的棋面「组块化」,把多个相关棋子打包成一个更高层次的概念。树形结构其实就是一种显式的组块化系统,每个中间节点就是一个组块,把下面的所有子空间压缩成一个概念。

层次化直接提升回忆效果

Bower 等人在 1969 年做了一个实验,他们给了受试者 112 个单词,要求记住。一组拿到的是随机长列表,另一组拿到了四层分类结构。记忆时间和单词量是一样的,唯一的变量是组织方式。结果拿到结构的那一组能回忆出 2 到 3 倍多的单词,而且这个优势随着轮次增加还在放大。

复杂工作天然就是层次化的

Newell 和 Simon 1972 年的研究把目标-子目标分解确立为人类解决复杂问题的标准模型。Annett 和 Duncan 1967 年提出的「层次化任务分析」至今仍在航空、麻醉等不容许状态丢失的领域使用。

当一个扁平的工作区管理工具遇到一个天然分层的工作任务时,总有人要为这种结构错配买单。在 space-tree 出现之前,这个人就是开发者自己。他得动用脑力把工作的自然层次投射到工具扁平结构上去。

认知负荷理论给这个成本起了名字

John Sweller 的认知负荷理论把工作记忆负担分成三类。

  • 内在负荷 ,任务本身的基本复杂度
  • 外在负荷 ,任务的呈现方式带来的额外负担
  • 相关负荷 ,构建有用心智模型付出的努力

扁平的工作区列表加在天然层次化的任务上就产生了外在负荷。开发者的工作记忆本来该花在工作内容上,结果全耗在工作区管理工具的结构上了。树形组织匹配了这种需求,消除了这类开销。

但也有条件

作者也引用了 HCI 研究的反面证据。菜单设计的深度与广度研究(Kiger 1984 年;Norman 1991 年)表明,在用户需要浏览别人定义的层次结构时,宽而浅的菜单通常优于深而窄的菜单。Hick 法则(Hick's Law)也提醒我们,人在 N 个选项中做出选择所需的反应时间与 log₂(N+1) 成正比,即选项越多反应越慢,但增速递减。

但工作区管理跟浏览菜单有一个关键区别。菜单的层次是别人定的,用户只能被动适应;工作区的树是用户自己动手建的,每一层放什么自己说了算。作者认为,当树的控制权在用户自己手里时,这些反面结论就不太适用了。他据此列出了四个条件来判断什么时候树形结构是对的。

  1. 底层任务本身有层次结构(修 bug、重构、排查问题都有)
  2. 用户自己创建和控制结构,不用去记忆别人定义的
  3. 每一层的兄弟空间数量不超过工作记忆上限。意思是,树形结构用层数(深度)而不是每层的分支数(宽度)来表达复杂度。一个深度为 3、每层 3-5 个兄弟的树,能表达几十个工作区的结构,但你在每一层需要记住的选项始终只有 3-5 个,不会撑爆工作记忆
  4. 支持识别而非回忆,modeline 显示当前位置和同级兄弟,用户认出自己在哪,不用硬记

space-tree 就是按这四个条件设计的。

space-tree 的设计:三张哈希表

space-tree 的代码很精干,目前大约 680 行 Elisp。核心思路只有两个(加上一个可选的命名扩展)。

每个空间用一个整数列表标识,从左到右读就是从根到目标的空间路径。比如 (2 1 3) 表示「从顶层空间 2 开始,下到它的第一个子空间,再到那个子空间的第三个子空间」。

三张哈希表分工明确。

  1. space-tree-tree 树形结构表,嵌套的哈希表,键是整数空间号,值是子表。这张表编码了「空间之间的父子关系」
  2. space-tree-address-wconf-tbl 窗口布局快照表,平的哈希表,键是地址列表,值是 window-state-get 返回的窗口状态数据。这张表编码了「每个空间里有什么窗口」
  3. space-tree-space-name-tbl 可选名称表,平的哈希表,只有用户主动命名的空间才会出现在这里。这张表编码了「哪些空间有名字」

三张表分开存,关联很清晰。第一张告诉你在哪,第二张告诉你有什么,第三张告诉你叫什么。

切换到某个空间的操作就是查第二张表,把结果传给 window-state-put 。创建子空间就是沿第一张表走到父节点,加一个新键,然后渲染一个空白(只有 scratch buffer)的工作区。删除空间就是删除结构表里的入口和对应的快照和名称。

除了这三张表,还有两个很小的状态。一个最近访问地址列表(用来实现「回到上一个空间」),一个记录当前地址的变量。全部状态库存就这么多。

上手配置 + 实操场景

安装用 use-package 从 GitHub 获取:

(use-package space-tree
  :ensure (:host github :repo "chiply/space-tree")
  :config
  (space-tree-init)

  ;; 顶层空间:Super + 数字
  (general-define-key
   "s-1" #'space-tree-to-1
   "s-2" #'space-tree-to-2
   "s-3" #'space-tree-to-3
   "s-4" #'space-tree-to-4
   "s-5" #'space-tree-to-5
   "s-6" #'space-tree-to-6
   "s-7" #'space-tree-to-7
   "s-8" #'space-tree-to-8
   "s-9" #'space-tree-to-9

   ;; 子空间(当前顶层空间下的第 2 层)
   "s-a" #'space-tree-sub-1
   "s-s" #'space-tree-sub-2
   "s-d" #'space-tree-sub-3
   "s-f" #'space-tree-sub-4
   "s-g" #'space-tree-sub-5

   ;; 孙空间(当前子空间下的第 3 层)
   "s-A" #'space-tree-sub-sub-1
   "s-S" #'space-tree-sub-sub-2
   "s-D" #'space-tree-sub-sub-3
   "s-F" #'space-tree-sub-sub-4
   "s-G" #'space-tree-sub-sub-5

   ;; 导航
   "M-S-<tab>"   #'space-tree-switch-space-by-name
   "M-<tab>"     #'space-tree-go-to-last-space
   "C-M-<tab>"   #'space-tree-go-right
   "C-M-S-<tab>" #'space-tree-go-left

   ;; 删除当前空间
   "s-_" #'space-tree-delete-space)

  ;; Evil 用户的快捷键
  (general-define-key
   :states '(normal visual)
   :keymaps 'override
   "gt" #'space-tree-switch-current-level
   "gT" #'space-tree-switch-space-by-digit-arg
   "g+" #'space-tree-create-space-top-level
   "gn" #'space-tree-create-space-current-level))

如果用不了 Super 键(被操作系统拦截了),换成 Hyper 或 Meta-Super 组合也行。不用 Evil 的话把后半段删掉。

作者给了一个具体场景。假设他在修一个测试失败。

  • s-2 切换到空间 2(顶层),先把测试运行器窗口摆好
  • s-aspace-tree-sub-1 )从空间 2 分支到 2.1,打开测试源码
  • 再按 s-sspace-tree-sub-2 )从空间 2 分支到 2.2,打开实现代码
  • s-dspace-tree-sub-3 )创建同级空间 2.3,放堆栈追踪

每个空间保持自己离开时的窗口布局。当前在哪个空间、下面是哪些分支,mode-line 上都有显示。修完后在空间 2.1、2.2、2.3 里分别按 s-_space-tree-delete-space ),整个子树一起消失,干净利落。

除此之外,space-tree 还支持给空间命名后用名称跳转、复制粘贴工作区布局、以及 mode-line 显示当前位置等操作,完整的命令列表在项目的 README

与同类工具的对比

作者整理了一张功能矩阵对比 8 个同类工具。核心维度是结构、命名、作用域、持久化。

工具 结构 命名 作用域 持久化
tab-bar-mode 扁平 可选 窗口 通过 desktop
eyebrowse 扁平 可选 窗口 可选
perspective.el 扁平 必有 缓冲区+窗口 支持
persp-mode 扁平 必有 缓冲区+窗口 支持
activities.el 扁平 必有 帧+窗口 支持(书签)
burly 扁平 必有 窗口+帧 支持(书签)
beframe 逐帧(每个 frame 只能看到从它自己里面打开的缓冲区) 不适用 逐帧缓冲区 不支持
workgroups2 扁平 必有 窗口 支持
space-tree 任意 可选 窗口 仅当前会话

space-tree 的独特之处首先在「结构」列,只有它是「任意深度」。其他所有工具都给你扁平的列表配上不同的附加功能,但空间的组织方式始终是一维的。

作者还特意列出了 space-tree 不做 的事,不做缓冲区作用域、不跨 session 恢复窗口、不跟 project.el 联动。这些功能不是做不了,而是刻意不做——因为缓冲区作用域和持久化已经有 perspective.elburly 等工具做得很好了,space-tree 跟它们自由组合就好。那些把缓冲区作用域和窗口布局打包在一起的工具,对不需要缓冲区作用域的用户来说就是捆绑销售。space-tree 的克制恰恰是为了兼容性:只做一件事(窗口布局管理),然后让 Emacs 的 project.elrecentfconsult-buffer 各司其职。

如果想试试,项目在 GitHub。README 里有完整的命令参考。

Emacs workspace space-tree