用 Emacs Dired 实现 em:退出文件管理器后,让终端自动切换目录

用 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 命令。

相关推荐
wt952712 分钟前
VMware 虚拟机磁盘扩容实战文档(Ubuntu 22.04 + LVM)
linux·后端
就像楞次定律24 分钟前
【Linux 学习记之二】C++ 构建体系与底层链接机制的深度透视(2)
linux·后端
无足鸟ICT40 分钟前
【RHCA+】slabtop命令(查看slab缓存)
linux
且从容.43 分钟前
Ubuntu 安装 Docker
linux·ubuntu·docker
青梅味猪大肠2 小时前
【操作系统-60】临近适应算法
java·linux·开发语言·嵌入式硬件·算法·操作系统
沫璃染墨2 小时前
从零入门计算机网络系列(十四):TCP报文与字节流本质——从数据封装到报文边界
linux·服务器·网络·网络协议·tcp/ip·tcp
CodeYp20193 小时前
第19讲:自旋锁
linux·自旋锁
此生决int3 小时前
Linux系列(08)——进程控制
linux·运维·驱动开发
Percep_gan4 小时前
Linux中安装gitea,很详细
linux·elasticsearch·gitea