用 Emacs Dired 实现 em:退出文件管理器后,让终端自动切换目录
日期:2026-11-03
我原来使用 Yazi 管理文件,在 zsh 中定义了一个 y() 函数。它有一个很方便的行为:在文件管理器里进入某个目录,退出后,外面的终端也已经切换到了那个目录,可以直接执行下一条命令。
最近逐渐把编辑工作放到 Emacs 中,于是想把这个习惯也带过来:能不能输入 em 打开 Dired,浏览和操作文件,退出后让 shell 自动 cd 到最后浏览的目录?
这次实现采用独立的终端 Emacs 进程,通过一个临时文件向 shell 返回目录,不需要新增插件。最终效果如下:
text
$ em
在 Dired 中进入 ~/Documents/notes
按 q 退出
$ pwd
/home/itkey/Documents/notes
本文记录实现思路、实际配置和验证结果。文中的两段完整代码对应本机的 ~/.zshrc 和 ~/.config/emacs/init.el。
从 Yazi 的 shell 函数说起
原来的配置是:
zsh
function y() {
local tmp="$(mktemp -t "yazi-cwd.XXXXXX")" cwd
yazi "$@" --cwd-file="$tmp"
IFS= read -r -d '' cwd < "$tmp"
[ -n "$cwd" ] && [ "$cwd" != "$PWD" ] && builtin cd -- "$cwd"
rm -f -- "$tmp"
}
这里的关键是 --cwd-file:Yazi 将最终目录写进文件,调用它的 shell 函数等到程序退出,再读取目录并执行 builtin cd。
Emacs 可以采用同样的方式。Dired 负责浏览目录,少量 Emacs Lisp 负责写出最终目录,zsh 函数负责读取和切换。
为什么必须由 shell 执行 cd
从终端启动 Emacs 后,Emacs 是 shell 启动的另一个进程。工作目录属于各自的进程状态,Emacs 无法通过改变自己的目录来改变父 shell 的工作目录。
因此,在 Emacs 中调用 cd 或执行一个外部 cd 命令,都不能直接让外面的终端切换目录。
整个流程需要明确分工:
本次会话的临时文件 独立 Emacs + Dired 当前 zsh 的 em() 函数 本次会话的临时文件 独立 Emacs + Dired 当前 zsh 的 em() 函数 #mermaid-svg-q80ysz7fEQJjaH0y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q80ysz7fEQJjaH0y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q80ysz7fEQJjaH0y .error-icon{fill:#552222;}#mermaid-svg-q80ysz7fEQJjaH0y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q80ysz7fEQJjaH0y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q80ysz7fEQJjaH0y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q80ysz7fEQJjaH0y .marker.cross{stroke:#333333;}#mermaid-svg-q80ysz7fEQJjaH0y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q80ysz7fEQJjaH0y p{margin:0;}#mermaid-svg-q80ysz7fEQJjaH0y .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q80ysz7fEQJjaH0y text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-q80ysz7fEQJjaH0y .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-q80ysz7fEQJjaH0y .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-q80ysz7fEQJjaH0y .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-q80ysz7fEQJjaH0y .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-q80ysz7fEQJjaH0y #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-q80ysz7fEQJjaH0y .sequenceNumber{fill:white;}#mermaid-svg-q80ysz7fEQJjaH0y #sequencenumber{fill:#333;}#mermaid-svg-q80ysz7fEQJjaH0y #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-q80ysz7fEQJjaH0y .messageText{fill:#333;stroke:none;}#mermaid-svg-q80ysz7fEQJjaH0y .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q80ysz7fEQJjaH0y .labelText,#mermaid-svg-q80ysz7fEQJjaH0y .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-q80ysz7fEQJjaH0y .loopText,#mermaid-svg-q80ysz7fEQJjaH0y .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-q80ysz7fEQJjaH0y .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-q80ysz7fEQJjaH0y .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-q80ysz7fEQJjaH0y .noteText,#mermaid-svg-q80ysz7fEQJjaH0y .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-q80ysz7fEQJjaH0y .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q80ysz7fEQJjaH0y .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q80ysz7fEQJjaH0y .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-q80ysz7fEQJjaH0y .actorPopupMenu{position:absolute;}#mermaid-svg-q80ysz7fEQJjaH0y .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-q80ysz7fEQJjaH0y .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-q80ysz7fEQJjaH0y .actor-man circle,#mermaid-svg-q80ysz7fEQJjaH0y line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-q80ysz7fEQJjaH0y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 创建空文件 通过环境变量传入起始目录和文件路径 浏览目录、操作文件、编辑内容 确认退出后写入目录,以 NUL 结尾 进程结束,shell 继续执行 读取返回目录 builtin cd 切换当前 shell 的目录 删除临时文件
em 必须是当前 shell 中的函数。若把整个逻辑放进一个单独执行的脚本,脚本里的 cd 只会改变脚本进程的目录。
先确定使用方式和返回规则
Dired 支持本地与远程目录,也能打开多个目录缓冲区。这次实现只向本地 shell 返回仍然存在的本地目录,返回规则如下:
| 操作 | 结果 |
|---|---|
em |
从当前 shell 的目录打开 Dired |
em ~/Downloads |
从指定目录打开 Dired |
Dired 中按 q |
退出 Emacs,并让 shell 切换到当前浏览的目录 |
编辑文件时按 C-c C-q |
退出 Emacs,并返回最后浏览的 Dired 目录 |
按 C-x C-c |
按普通方式退出,shell 不切换目录 |
| 取消退出 | 留在 Emacs 中,不提交返回目录 |
| Emacs 以非零状态结束 | shell 不切换目录 |
"当前目录"指 Dired 正在显示的目录。只把光标移到一个子目录名字上,还没有进入它时,返回的仍是当前列表所属的目录。如果使用了 Dired 的子目录插入功能,则以当前行所属的目录为准。
文件管理能力由内置 Dired 提供。图标可以单独通过插件添加,但不影响这次目录返回功能。Dired 官方手册介绍了它的目录浏览与文件操作能力。
第一步:在 zsh 中定义 em()
将下面的函数加入 ~/.zshrc:
zsh
function em() {
if (( $# > 1 )); then
print -u2 -- '用法:em [目录]'
return 2
fi
local em_start_directory="${1:-$PWD}" em_cwd_file em_cwd em_exit_code=0
if [[ ! -d "$em_start_directory" ]]; then
print -u2 -- "em:目录不存在:$em_start_directory"
return 1
fi
em_start_directory="${em_start_directory:a}"
em_cwd_file="$(mktemp -t emacs-cwd.XXXXXX)" || return
{
if MY_EM_CWD_FILE="$em_cwd_file" \
MY_EM_START_DIRECTORY="$em_start_directory" \
command emacs -nw --funcall my/em-start; then
if IFS= read -r -d '' em_cwd < "$em_cwd_file" &&
[[ -n "$em_cwd" && -d "$em_cwd" && "$em_cwd" != "$PWD" ]]; then
builtin cd -- "$em_cwd" || em_exit_code=$?
fi
else
em_exit_code=$?
fi
} always {
command rm -f -- "$em_cwd_file"
}
return "$em_exit_code"
}
函数只接受零个或一个目录参数。没有参数时使用 $PWD,目录不存在时直接报错,不启动 Emacs。
${em_start_directory:a} 是 zsh 的路径修饰语法,用来将起始目录转换成绝对路径。后面的 always 结构同样属于 zsh,因此这段函数需要在 zsh 中使用,不能原样复制到 Bash。
用环境变量传递路径
启动 Emacs 的部分是:
zsh
MY_EM_CWD_FILE="$em_cwd_file" \
MY_EM_START_DIRECTORY="$em_start_directory" \
command emacs -nw --funcall my/em-start
两个环境变量分别传入临时文件路径和起始目录;-nw 指定终端模式,--funcall my/em-start 让 Emacs 在读取配置后执行初始化函数。
这里没有把用户输入的路径拼接进 --eval 表达式。路径作为环境变量的值传递,可以避免同时处理 shell 引用和 Lisp 字符串转义。
只处理成功且完整的返回结果
shell 只有在 Emacs 退出状态为零、成功读取到 NUL 结束的路径、路径非空且目录仍存在时,才会执行 builtin cd。已经位于目标目录时则跳过切换。
Emacs 以非零状态结束时,函数保留它的退出状态。目标目录在检查后又被删除、导致 cd 失败时,也会返回 cd 的失败状态。
临时文件为空或缺少 NUL 终止符时,函数不会切换目录;这类情况本身不会把一次正常退出改成错误退出。
用 always 收尾
临时文件清理放在:
zsh
} always {
command rm -f -- "$em_cwd_file"
}
这样,正常返回和一般执行错误都能进入清理段。zsh 对这一结构的说明见 Shell Grammar 中的 always。它不意味着在 shell 被强制杀死或机器断电时仍能完成清理。
每次调用 em 都使用 mktemp 创建自己的结果文件,因此不同终端中的会话不会共用一个固定的目录记录文件。
第二步:在 Emacs 中增加会话逻辑
将下面的代码加入 ~/.config/emacs/init.el。如果使用其他 Emacs 配置目录,放入实际加载的初始化文件即可。
elisp
(defvar my/em-result-file nil
"本次 em 会话向 shell 返回目录的临时文件。")
(defvar my/em-last-directory nil
"本次 em 会话最后浏览的 Dired 目录。")
(defvar my/em-exit-directory nil
"用户确认退出后,要写入临时文件的目录。")
(defun my/em-remember-directory ()
"记录当前浏览的 Dired 目录,供打开文件后返回 shell 使用。"
(when (derived-mode-p 'dired-mode)
(setq my/em-last-directory (dired-current-directory))))
(defun my/em-write-directory ()
"仅在确认结束 em 会话后写入目录,以 NUL 结尾保留特殊文件名。"
(when (and my/em-result-file my/em-exit-directory)
(let ((coding-system-for-write 'utf-8-unix))
(write-region (concat my/em-exit-directory "\0")
nil my/em-result-file nil 'silent))))
(defun my/em-finish ()
"退出本次 em 会话,并让调用它的 shell 切换到浏览的目录。"
(interactive)
(unless my/em-result-file
(user-error "请从 shell 的 em 函数启动此会话"))
(my/em-remember-directory)
(let ((directory my/em-last-directory))
(unless (and directory
(not (file-remote-p directory))
(file-directory-p directory))
(user-error "只能返回仍然存在的本地目录"))
;; 动态绑定只在这次退出期间生效;取消退出后不会留下待返回目录。
(let ((my/em-exit-directory directory))
(save-buffers-kill-emacs))))
(define-minor-mode my/em-dired-mode
"仅在 em 启动的 Dired 中,让 q 退出并返回目录。"
:lighter nil
:keymap (let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'my/em-finish)
map))
(defun my/em-start ()
"由 shell 的 em 函数调用,启动独立的终端 Dired 会话。"
(interactive)
(require 'dired)
(let ((result-file (getenv "MY_EM_CWD_FILE"))
(directory (getenv "MY_EM_START_DIRECTORY")))
(unless (and result-file directory (file-directory-p directory))
(user-error "请使用 shell 的 em 函数启动"))
(setq my/em-result-file result-file)
(add-hook 'dired-mode-hook #'my/em-dired-mode)
(add-hook 'post-command-hook #'my/em-remember-directory)
(add-hook 'kill-emacs-hook #'my/em-write-directory)
(global-set-key (kbd "C-c C-q") #'my/em-finish)
(dired directory)
(my/em-dired-mode 1)
(my/em-remember-directory)
(message "em:q 退出并切换目录;C-c C-q 在编辑文件时返回;C-x C-c 退出不切换")))
这段代码定义了三个会话状态:
| 变量 | 用途 |
|---|---|
my/em-result-file |
本次会话用于返回目录的临时文件路径 |
my/em-last-directory |
最后浏览的 Dired 目录 |
my/em-exit-directory |
本次退出请求要提交的目录,平时为 nil |
my/em-start 只会通过 shell 函数里的 --funcall 调用。普通启动 Emacs 时,这些函数虽然已经定义,但不会安装本次会话的钩子和按键设置。
为什么要记住最后浏览的目录
如果只在退出时读取当前缓冲区的 default-directory,打开文件或切换缓冲区后,得到的目录可能与最后浏览的位置不同。
因此,my/em-remember-directory 只在当前缓冲区属于 Dired 时更新记录。会话启动时记录一次,之后在 post-command-hook 中随着用户操作更新。
从 Dired 进入文件后,记录会保留。此时按 C-c C-q,可以返回先前浏览的目录,而不会因为切换到其他缓冲区而改变返回目标。
具体取值使用 dired-current-directory。它能识别当前行所属的子目录,比始终使用整个缓冲区的 default-directory 更贴近 Dired 的浏览位置。
最关键的细节:请求退出,不等于已经退出
一个容易写出的初版是:先把目录写入文件,再调用退出命令。
问题在于,退出可能被取消。例如,用户还有未保存的文件,按下退出键后,又决定继续编辑。如果目录已经提前写入临时文件,后面再通过普通 C-x C-c 退出,就可能意外切换 shell 目录。
这次实现把"选择返回目录"和"真正写出目录"拆开:
elisp
(let ((my/em-exit-directory directory))
(save-buffers-kill-emacs))
save-buffers-kill-emacs 保留 Emacs 正常的保存询问和退出确认。真正结束进程时,kill-emacs-hook 才会调用 my/em-write-directory 写入路径。Emacs Lisp 的退出流程文档说明了退出询问与 kill-emacs-hook 的关系。
这里的 my/em-exit-directory 用 defvar 定义,所以即使配置文件启用了 lexical-binding: t,它仍是特殊变量,let 会对它进行动态绑定。退出钩子在这次调用期间能读到绑定的目录;如果取消退出并返回编辑状态,或执行过程退出这个 let,变量会恢复原值 nil。动态绑定文档给出了对应规则。
后续再按普通 C-x C-c 时,写入函数看到待返回目录为 nil,便不会提交目录。这也是取消退出后不会留下旧切换指令的原因。
为什么用 NUL,而不是换行
Emacs 写出的内容是:
elisp
(concat my/em-exit-directory "\0")
shell 对应使用:
zsh
IFS= read -r -d '' em_cwd < "$em_cwd_file"
两端约定以 NUL 作为路径结束标记。Unix 路径分量中不能包含 NUL,但可以包含空格、反斜杠,甚至换行。因此,这个结束标记能避免把路径本身的换行误当成记录结尾。
IFS= 避免默认的字段分隔处理,-r 避免解释反斜杠;读取和执行 cd 时也都保留双引号。返回目录始终作为路径数据使用,不经过 eval,目录名里的 $() 或反引号不会被当作命令执行。
写出时明确使用 utf-8-unix。本次实现和验证面向 UTF-8 环境;未对包含非 UTF-8 原始字节的文件名作兼容性承诺。
按键只影响 em 启动的会话
Dired 原本的 q 通常用于退出目录窗口。这里没有直接修改全局的 dired-mode-map,而是定义了一个缓冲区局部的 minor mode:my/em-dired-mode。
只有运行 my/em-start 后,才会把它加入本次进程的 dired-mode-hook。因此,经 em 启动的 Dired 中,q 会调用 my/em-finish;普通启动的 Emacs 中,Dired 保持原来的按键行为。
C-c C-q 也只会在这次独立进程中绑定,供离开 Dired、进入文件编辑缓冲区后使用。这个进程结束时,相应的会话设置也随之消失。
加载和日常使用
保存两处配置后,在当前 zsh 中执行:
zsh
source ~/.zshrc
然后可以使用:
zsh
em
em ~/Downloads
em "$HOME/带空格的目录"
每次 em 都会启动新的 Emacs 进程并重新读取配置,因此保存 init.el 后,下次调用即可使用新逻辑。
在 Dired 中可以使用这些基础按键:
| 按键 | 操作 |
|---|---|
RET |
进入目录或打开文件 |
^ |
返回上级目录 |
m |
标记文件 |
C |
复制文件 |
R |
移动或重命名文件 |
d、x |
标记删除,再执行删除 |
q |
在 em 会话中退出并让 shell 切换目录 |
验证了哪些行为
这次验证包含真实终端操作,也包含对退出状态和钩子的模拟测试。两者分别检查交互是否连通,以及不容易稳定复现的边界分支。
真实终端测试通过伪终端启动 zsh 和终端 Emacs,发送实际按键,并检查 Emacs 结束后的 shell 工作目录:
| 场景 | 结果 |
|---|---|
从起始目录浏览到另一个目录,按 q |
shell 切换到浏览的目录 |
浏览到另一个目录,按 C-x C-c |
shell 保持原目录 |
从 Dired 打开文件,按 C-c C-q |
shell 切换到最后浏览的 Dired 目录 |
测试目录包含中文、空格、$() 和反引号,以上三项均通过。
另外,Emacs Lisp 的四项 ERT 测试验证了:取消退出后不写入目录、执行退出写入逻辑时产生 NUL 结束的记录、拒绝返回远程目录,以及普通 Dired 的 q 保持原行为。这些退出分支采用替换退出函数的方式测试,并没有用自动按键完整走过所有未保存文件的询问界面。
shell 侧通过替代 Emacs 命令分别模拟正常返回、非零退出、空结果和缺少 NUL 的结果,检查是否切换目录、是否保留退出状态,以及结果文件是否清理。四种情况均符合预期,也验证了已有同名 emacs 别名时的调用行为。
最后检查了 .zshrc 的语法和 init.el 的括号配对。临时测试脚本及测试目录在验证后已删除。
为什么这一版使用独立进程
独立进程的生命周期容易理解:em() 启动 Emacs,等待它退出,然后读取结果并切换目录。配置加载、目录记录和退出钩子都属于这一个进程,不需要协调常驻服务里的多个客户端。
代价是每次调用都要启动 Emacs 并加载配置。这次没有测量启动耗时;如果以后频繁调用,启动等待成为实际问题,再考虑迁移到 emacsclient -t。
迁移时不能只替换启动命令:客户端界面结束时,后台 Emacs 进程可能继续运行,kill-emacs-hook 也就不能代表"这一次文件管理会话结束"。需要改为按客户端会话管理结果文件和目录状态。emacsclient 官方文档说明了终端客户端与进程退出的区别。
目前,这个版本已经打通了我需要的流程:在终端输入 em,用 Dired 浏览和操作文件,需要时直接编辑,最后回到选定目录继续执行 shell 命令。