文件管理与笔记系统

Dired 是 Emacs 里被低估最严重的部分:它本身就是一套完整的文件管理器;本仓库的文档体系又恰好是纯 Markdown 加双链接,因此在 Emacs 里编辑它并不需要额外的笔记软件。


一、Dired:内置文件管理器

Dired 全名 Directory Editor,随 Emacs 一起提供,不需要装任何包。它的心智模型是:把一个目录的内容列成一个普通缓冲区,每一行是一个文件,然后在这张列表上用普通的光标移动、标记、搜索加批量命令操作文件。理解了这一点,就能理解 Dired 为什么键位这么多、为什么它比图形文件管理器更适合批量操作。

术语提醒:Dired 的”缓冲区”是那个目录列表本身,不是文件内容。在 Dired 里按 RETf 是”用新缓冲区打开这一行的文件”,而只移动光标不会打开任何东西。这一点与图形文件管理器的”单击选中、双击打开”不同。

1.1 打开与基本约定

键位或命令作用
C-x ddired,提示输入目录名
C-x C-f 输入一个目录名直接打开该目录的 Dired
C-x C-jdired-jump,跳到当前文件所在目录并把光标放在该文件上
C-x 4 C-jdired-jump-other-window,在另一个窗口打开
M-x dired-jump在 Dired 缓冲区里跳到上一层

Dired 的标记(mark)与标记删除(flag)是两套机制:标记用 m,被标记的行行首显示 *,所有大写字母开头的命令和 x 之外的大多数批量命令作用在”已标记的文件”上;如果没有任何标记,则作用在光标所在的那一行。删除是两步的:d 只是打上删除标记(行首显示 D),必须再按 xdired-do-flagged-delete)才真正删除,这是内置的安全网。

隐藏文件(点开头的文件)默认显示。要隐藏它们用 (dired-hide-details-mode)隐藏细节列,或者用 dired-x 的 C-x M-odired-omit-mode)省略匹配 dired-omit-files 的行。

1.2 键位表(按功能分区)

移动与浏览:

键位命令说明
n / p / SPC / S-SPCdired-next-line / dired-previous-line上下移动
< / >dired-prev-dirline / dired-next-dirline在子目录行之间跳
^dired-up-directory回到上一层目录
M-{ / M-}dired-prev-marked-file / dired-next-marked-file在已标记文件之间跳
jdired-goto-file按文件名跳转
M-Gdired-goto-subdir跳到已插入的子目录
grevert-buffer重新读取目录(刷新)
ldired-do-redisplay重绘指定行

标记与取消:

键位命令说明
mdired-mark标记当前行
udired-unmark取消当前行标记
DELdired-unmark-backward向上取消标记
tdired-toggle-marks反转所有标记
Udired-unmark-all-marks取消全部标记
M-DELdired-unmark-all-files按标记字符批量取消
* /dired-mark-directories标记所有目录
* *dired-mark-executables标记所有可执行文件
* @dired-mark-symlinks标记所有符号链接
* %% mdired-mark-files-regexp按正则标记
% gdired-mark-files-containing-regexp标记内容匹配正则的文件
* sdired-mark-subdir-files标记子目录里的文件
* Ndired-number-of-marked-files显示已标记文件数

打开与查看:

键位命令说明
f / e / RETdired-find-file在当前窗口打开
odired-find-file-other-window在另一窗口打开
C-odired-display-file在另一窗口显示但不切过去
adired-find-alternate-file打开文件并关掉当前 Dired 缓冲区
vdired-view-file以只读方式查看
Edired-do-open用系统默认程序打开(在 Emacs 30 中可用)
Wbrowse-url-of-dired-file用浏览器打开
ydired-show-file-type查看文件类型(调用 file 命令)
=dired-diff与当前文件比较

批量操作(复制、改名、删除、链接、权限):

键位命令说明
Cdired-do-copy复制
Rdired-do-rename重命名或移动
Ddired-do-delete直接删除(不再确认一次 x
d 然后 xdired-flag-file-deletiondired-do-flagged-delete先标记再删除
Hdired-do-hardlink建硬链接
Sdired-do-symlink建符号链接
Ydired-do-relsymlink建相对路径的符号链接
Mdired-do-chmod改权限
Odired-do-chown改所有者
Gdired-do-chgrp改所属组
Tdired-do-touch改时间戳
+dired-create-directory新建目录
Zdired-do-compress压缩或解压选中的文件
cdired-do-compress-to压缩成指定归档文件

压缩与解压的行为值得说明:Z 作用在 .tar.gz.zip 等归档文件上是解压,作用在普通文件上是压缩成同名归档;压缩包不会自动展开成一个目录,需要解压后再整理。

搜索与替换:

键位命令说明
Adired-do-find-regexp在标记的文件里搜索正则(结果进 *xref* 缓冲区)
Qdired-do-find-regexp-and-replace在标记的文件里搜索并替换
M-s a C-sdired-do-isearch在标记的文件里跨文件增量搜索
M-s f C-sdired-isearch-filenames只在文件名里搜索
M-s f C-M-sdired-isearch-filenames-regexp只在文件名里正则搜索

执行外部命令:

键位命令说明
!dired-do-shell-command对标记的文件执行 shell 命令
Xdired-do-shell-command同上(旧键位,仍保留)
&dired-do-async-shell-command异步执行,自动追加 &
Idired-do-info在 Info 里打开
Ndired-do-man查看 man 手册
Pdired-do-print打印

排序、过滤与显示:

键位命令说明
sdired-sort-toggle-or-edit切换按名字与按时间排序,或编辑 ls 开关
(dired-hide-details-mode隐藏权限、大小、时间等细节列
M-$dired-hide-all折叠所有子目录
$dired-hide-subdir折叠或展开当前子目录
idired-maybe-insert-subdir在当前缓冲区里插入子目录内容
kdired-do-kill-lines从显示里移除某些行(不动磁盘文件)
wdired-copy-filename-as-kill复制文件名到 kill-ring(加前缀参数给完整路径)

ik 的组合是 Dired 独有的用法:i 把子目录内容平铺插入到当前列表里,之后可以在一个缓冲区里跨目录标记文件、批量操作,k 只是把行隐藏起来,不影响磁盘。

1.3 dired-do-shell-command 的替换字符

!X 都调用 dired-do-shell-command,它识别的替换字符只有 *? 两个(不是 %),规则由 docstring 明确规定:

命令里出现的字符行为
*(两侧有空白)整条命令只执行一次,所有标记文件的文件名替换到这个位置
?(两侧有空白,或写成 `?`每个文件执行一次命令,文件名替换到 ? 的位置
两者都没有每个文件执行一次,文件名追加到命令末尾(用空格分隔)
*? 出现在非空白包围的位置不特殊处理,交给 shell,但会先要求确认
命令以 & 结尾异步执行,输出进异步 shell 缓冲区
命令以 ;;& 结尾逐个文件在后台顺序执行

举例说明(选中若干 .txt 文件后):

  • wc -l *:一次调用 wc -l 并传入所有文件名,输出一份总表。
  • wc -l ?:每个文件调用一次 wc -l
  • wc -l:等同于 wc -l ? 的效果,文件名会追加到末尾。
  • tar czf out.tar.gz *"":想用 shell 的通配符 * 而不是 Dired 的替换时,写成 *"",等价于 shell 里的 *

1.4 % 前缀:正则批量操作

% 在 Dired 里是一个前缀键,它对应的是一整组”按正则作用”的命令:

键位命令说明
% mdired-mark-files-regexp文件名匹配正则的标记
% gdired-mark-files-containing-regexp文件内容匹配正则的标记
% ddired-flag-files-regexp文件名匹配正则的打上删除标记
% udired-upcase把标记文件的文件名改成大写
% ldired-downcase改成小写
% Rdired-do-rename-regexp按正则重命名
% Cdired-do-copy-regexp按正则复制
% S / % Ydired-do-symlink-regexp / dired-do-relsymlink-regexp按正则建符号链接
% Hdired-do-hardlink-regexp按正则建硬链接

dired-do-rename-regexp 的新名字里可以使用替换引用 \&(整个匹配)与 \<n>(第 n 个捕获组),这与 query-replace-regexp 的写法一致。例如把 IMG_1234.JPG 改成 2026-01-01.jpg 这类需求,用 % RIMG_\([0-9]+\)\.JPG\1.jpg 就能一次完成。

1.5 wdired:把文件名当文本编辑

wdired(Writable Dired)允许直接编辑 Dired 缓冲区里的文件名与权限位,编辑完成后一次性提交。这是批量改名最直观的方式,因为可以用上 Emacs 全部的文本编辑能力:正则替换、列编辑、宏、多个光标。

进入与提交:

键位命令说明
C-x C-qdired-toggle-read-only在 Dired 里进入 wdired(该键在 Dired 中被重映射)
C-c C-cC-x C-swdired-finish-edit提交修改,真正改磁盘
C-c C-kwdired-abort-changes放弃修改
C-x C-qwdired-exit退出 wdired

在 wdired 模式下,M-x replace-regexpM-x query-replace-regexp 都可以直接用在文件名上;由于每行还包括权限与时间列,正则要写得足够具体,只匹配文件名部分。upcase-wordcapitalize-worddowncase-word 这三个命令在 wdired 里被重映射到 wdired-upcase-word 等版本,只作用于文件名而不会破坏权限列。

一个完整的批量改名流程(把 IMG_0001.JPG 这类文件改成 photo-0001.jpg):

1. C-x d ~/Pictures RET            打开目录
2. C-x C-q                         进入 wdired
3. M-x replace-regexp RET
   IMG_\([0-9]+\)\.JPG RET          匹配串(注意只匹配文件名部分)
   photo-\1.jpg RET                替换串
4. 检查改动;不放心可以先只改一行看效果
5. C-c C-c                         提交;此时才会真正重命名文件

如果改错了,在提交前按 C-c C-k 全部放弃;提交之后 Dired 会重新读取目录,但磁盘上的改名无法用 undo 撤销,重要操作前先确认目录已提交进版本控制或已有备份。

权限位也能在 wdired 里直接编辑,但需要先把 wdired-allow-to-change-permissions 设为 t(默认是 nil,此时权限列不可改):光标移到行首的权限字符上修改,例如把 -rw-r--r-- 改成 -rwxr-xr-x,提交时同样生效。

1.6 dired-x:内置的增强模块

dired-x 也是 Emacs 自带的,(require 'dired-x)M-x load-library RET dired-x 之后生效:

(require 'dired-x)
 
;; 打开 Dired 时自动省略匹配 dired-omit-files 的行
(add-hook 'dired-mode-hook #'dired-omit-mode)
 
;; 默认省略点文件与备份文件
(setq dired-omit-files
      (concat "\\`[.]?#\\|\\`[.][.]?\\'"
              "\\|^#.*#$\\|\\.pyc\\'\\|\\.elc\\'\\|\\.o\\'"))
 
;; 关心目录体积时打开,否则大目录会变慢
(setq dired-omit-size-limit nil)

dired-x 提供的主要能力:省略文件(C-x M-o)、dired-jumpC-x C-j,跳回当前文件在 Dired 里的位置)、按文件类型猜测打开方式(dired-guess-shell-alist-user,让 !& 在文件上自动补出合适的命令)。

;; 让 Dired 里对 PDF 按 & 时默认用 zathura 打开
(setq dired-guess-shell-alist-user
      '(("\\.pdf\\'" "zathura")
        ("\\.mp4\\'" "mpv")
        ("\\.epub\\'" "emacsclient -c --eval '(nov \"%s\")'")))

1.7 第三方 Dired 增强

作用仓库
dired-hacks一组独立小组件,包含 dired-subtree、dired-narrow、dired-filter、dired-collapse 等https://github.com/Fuco1/dired-hacks
diredfl给 Dired 加更明显的字体着色https://github.com/purcell/diredfl
dired-hide-dotfiles一键切换点文件的显示https://github.com/mattiasb/dired-hide-dotfiles

dired-subtree 把子目录内容内联展开成树状缩进显示,比内置的 i 更接近现代文件树的观感:

(use-package dired-subtree
  :ensure t
  :after dired
  :bind (:map dired-mode-map
              ("TAB" . dired-subtree-toggle)
              ("<tab>" . dired-subtree-toggle)
              ("<backtab>" . dired-subtree-cycle))
  :custom
  (dired-subtree-use-backgrounds nil))
 
;; 简单的外观增强,可选
(use-package diredfl
  :ensure t
  :hook (dired-mode . diredfl-mode))

1.8 在 Dired 里用 Elisp 批量处理文件

除了 shell 命令,还可以用 Elisp 直接处理已标记的文件,好处是跨平台(不依赖 shell 工具)且能复用 Emacs 的读写能力。核心函数是 dired-get-marked-files,它返回已标记文件的绝对路径列表;没有标记时返回光标处文件。

(defun my/dired-count-lines ()
  "统计已标记文本文件的行数与字符数,结果打印到回显区。"
  (interactive)
  (let ((files (dired-get-marked-files)))
    (dolist (file files)
      (when (file-regular-p file)
        (with-temp-buffer
          (insert-file-contents file)
          (message "%s%d 行,%d 字符"
                   (file-name-nondirectory file)
                   (count-lines (point-min) (point-max))
                   (buffer-size)))))))
 
(defun my/dired-rename-to-lowercase ()
  "把已标记文件的文件名改成全小写(扩展名也一起改)。"
  (interactive)
  (let ((files (dired-get-marked-files)))
    (dolist (file files)
      (let* ((dir (file-name-directory file))
             (name (file-name-nondirectory file))
             (new (downcase name)))
        (unless (string= name new)
          (rename-file file (expand-file-name new dir) nil)))))
  (revert-buffer))
 
;; 绑定到 Dired 的 C-c l
(with-eval-after-load 'dired
  (keymap-set dired-mode-map "C-c l" #'my/dired-count-lines)
  (keymap-set dired-mode-map "C-c L" #'my/dired-rename-to-lowercase))

dired-do-find-regexpA)与 dired-do-find-regexp-and-replaceQ)值得单独强调:前者在标记的文件里搜索并把结果整理进 xref 缓冲区,可以逐个跳转;后者相当于对多个文件做一次可控的全局替换,是”在这批文件里把这个标识符改掉”的标准做法。

1.9 用 TRAMP 管理远程目录

Dired 可以直接打开远程目录,语法是 TRAMP 的路径前缀:

路径写法含义
/ssh:user@host:/var/log/通过 SSH 打开远程目录
/ssh:user@host#2222:/srv/指定端口
/sudo:root@localhost:/etc/用 sudo 打开本地需要 root 的目录
/scp:user@host:/tmp/通过 scp 传输
/docker:容器名:/app/进入容器文件系统(需要容器里有可用工具)

在 Dired 里的所有批量操作在远程目录上同样有效,但代价是每次操作都可能触发一次网络往返,因此:

;; 打开远程 Dired 时自动关闭细节列,减少要传输的元数据
(add-hook 'dired-mode-hook
          (lambda ()
            (when (file-remote-p default-directory)
              (dired-hide-details-mode 1))))
 
;; 让 TRAMP 复用 SSH 连接,避免每个操作都重新握手
(setq tramp-verbose 1)

更完整的远程方案(连接复用、控制台与远程编译)见 TRAMP 远程开发


二、文件树方案:对比与选择

2.1 五种方案的定位

方案类型定位依赖适合的场景
treemacs独立包完整的项目侧边栏,支持多工作区、Git 状态、图标无硬依赖想长期用固定侧边栏管理多个项目
neotree独立包轻量文件树,模仿 Vim 的 NERDTree无硬依赖从 Vim 迁过来、要熟悉操作方式
dirvish独立包增强 Dired 本体,带预览、头部行、快速跳转建议装 fd 以获得 dirvish-fd主要工作流是 Dired,想要更好的观感
dired-sidebar独立包把 Dired 塞进一个侧边窗口只想偶尔开一个 Dired 侧栏
speedbarEmacs 内置极简的文件与标签侧栏老配置、不想装包

一个直接的建议:如果已经在用 Dired 并且键位已经形成肌肉记忆,装 dirvish 的收益最大,因为它不改变 Dired 的操作方式;如果需要”多个项目之间快速切换”的工作区概念,选 treemacs;neotree 与 dired-sidebar 定位接近,选一个即可;speedbar 只在完全不想增加依赖时使用。

2.2 treemacs 最小配置

官方仓库:https://github.com/Alexander-Miller/treemacs。treemacs 有一个明确的设计约定:它不为全局入口函数提供默认键位,需要用户自己指定。

(use-package treemacs
  :ensure t
  :bind (("C-c t" . treemacs)                 ; 打开或关闭侧边栏
         ("C-c T" . treemacs-select-window)   ; 把光标移到侧边栏
         :map treemacs-mode-map
         ("C-c C-p a" . treemacs-add-project-to-workspace))
  :custom
  (treemacs-width 32)
  (treemacs-indentation 2)
  (treemacs-sorting 'alphabetic-case-insensitive-asc)
  (treemacs-ignored-file-predicates
   '(treemacs--std-ignore-file-predicate))
  :config
  ;; 光标跟随当前文件高亮
  (treemacs-follow-mode 1)
  ;; 目录变化时自动刷新
  (treemacs-filewatch-mode 1)
  ;; 在文件名旁边显示 Git 状态,仓库很大时可以关掉
  (treemacs-git-mode 'simple))

treemacs 的常用键位分几个前缀:项目操作是 C-c C-pa 添加项目、d 移除项目、r 改名、c c 折叠项目),工作区操作是 C-c C-wa 新建、s 切换、n 下一个),打开节点是 o 前缀(ooRET 不切分窗口打开、ov 垂直切分、oh 水平切分、ox 用外部程序打开)。在侧边栏里按 ? 会弹出完整的键位提示。

2.3 dirvish 最小配置

官方仓库:https://github.com/alexluigit/dirvish。dirvish 的理念是”增强 Dired 而不是取代它”,因此它没有独立的模式,而是通过 dirvish-override-dired-mode 让 Dired 相关命令走 dirvish 的实现。

(use-package dirvish
  :ensure t
  :init
  ;; 让 dired 与 find-file 走 dirvish 的实现
  (dirvish-override-dired-mode)
  :custom
  ;; 头部行显示的信息;按需增减
  (dirvish-attributes
   '(vc-state subtree-state nerd-icons collapse git-msg file-time file-size))
  ;; 布局:(父目录行数 侧栏比例 预览比例)
  (dirvish-default-layout '(1 0.11 0.55))
  (dirvish-hide-details nil)
  :bind
  (("C-c d" . dirvish)                  ; 在当前目录打开
   ("C-c f" . dirvish-fd)               ; 用 fd 全盘查找后打开目录
   :map dirvish-mode-map
   ("a" . dirvish-quick-access)         ; 快速跳转到常用目录
   ("f" . dirvish-file-info-menu)       ; 文件信息菜单
   ("y" . dirvish-yank-menu)            ; 复制与移动文件
   ("TAB" . dirvish-subtree-toggle)     ; 展开子目录
   ("M-e" . dirvish-emerge-mode)        ; 按属性分组
   ("M-s" . dirvish-history-jump)))     ; 历史目录跳转

nerd-icons 属性需要安装 nerd-icons 包与对应的字体;不想折腾字体的话,把 nerd-iconsdirvish-attributes 里删掉即可。此外 dirvish-fd 需要系统里有 fd(https://github.com/sharkdp/fd),没有 fd 时会自动退回内置实现。

2.4 轻量替代:neotree 与 dired-sidebar

;; neotree:模仿 NERDTree 的轻量文件树
(use-package neotree
  :ensure t
  :bind (("C-c n" . neotree-toggle)
         ("C-c N" . neotree-project-dir))
  :custom
  (neo-theme 'ascii)
  (neo-smart-open t))
;; 侧栏里的常用命令:neotree-dir 选目录、neotree-find 定位到当前文件、
;; neotree-change-root 换根目录、neotree-hidden-file-toggle 切换点文件、
;; neotree-refresh 刷新、neotree-rename-node 与 neotree-delete-node 改名与删除
 
;; dired-sidebar:把 Dired 放进侧栏,完全复用 Dired 键位
(use-package dired-sidebar
  :ensure t
  :bind (("C-c s" . dired-sidebar-toggle-sidebar)
         ("C-c S" . dired-sidebar-toggle-with-current-directory))
  :custom
  (dired-sidebar-theme 'ascii)
  (dired-sidebar-use-term-integration nil))
;; 其他命令:dired-sidebar-show-sidebar 显示、dired-sidebar-hide-sidebar 隐藏、
;; dired-sidebar-jump-to-sidebar 跳到侧栏

2.5 文件树到底需不需要

这是一个值得诚实回答的问题。文件树的实际价值有三个:一是让你知道项目里有什么;二是让你不用记路径就能打开文件;三是提供项目级的”根”概念。

但在 Emacs 里,这三件事都有更快的替代:

  • C-x C-f 加补全(vertico 与 consult 环境下的补全候选是模糊匹配的路径)打开文件,比在文件树里一层层展开更快。
  • C-x p fproject-find-file)在项目内部按文件名打开,完全不需要浏览目录结构。
  • C-c s(consult-ripgrep)按内容找文件,通常比记住文件名更符合直觉。

因此建议是:把文件树当作”偶尔要看全局结构”的工具,而不是每次打开文件都要经过的入口。默认不开侧边栏、需要时按一下快捷键打开,是长期使用最舒服的形态。

flowchart TD
    A["需要操作文件"] --> B{"知道文件名吗"}
    B -->|"知道"| C["C-x p f 在项目内打开"]
    B -->|"不知道,记得内容"| D["consult-ripgrep 按内容搜索"]
    B -->|"想看目录结构"| E{"要做什么"}
    E -->|"浏览与排序"| F["dired 加 dirvish"]
    E -->|"批量改名"| G["wdired 编辑文件名"]
    E -->|"批量执行命令"| H["dired-do-shell-command"]
    E -->|"跨目录整理"| I["dired 的 i 插入子目录"]
    E -->|"长期侧边栏"| J["treemacs 或 neotree"]
    D --> K["在结果里直接编辑并保存"]
    G --> L["C-c C-c 提交改名"]

三、快速打开文件

3.1 recentf 与 savehist

recentf-mode 记录最近打开的文件,是”刚才那个文件在哪”的标准答案;savehist-mode 把各种 minibuffer 历史持久化,让补全候选在重启后仍然可用。

;; 最近文件列表
(recentf-mode 1)
(setq recentf-max-saved-items 300)     ; 最多保存 300 条
(setq recentf-auto-cleanup 'mode)      ; 打开 recentf 时清理已不存在的文件
(setq recentf-save-file (expand-file-name "recentf" user-emacs-directory))
 
;; 把不值得记录的内容排除掉
(add-to-list 'recentf-exclude
             (expand-file-name "elfeed" user-emacs-directory))
(add-to-list 'recentf-exclude "\\.git/")
(add-to-list 'recentf-exclude "^/tmp/")
 
;; 持久化 minibuffer 历史与 kill-ring
(savehist-mode 1)
(setq savehist-file (expand-file-name "history" user-emacs-directory))
(setq savehist-additional-variables '(search-ring regexp-search-ring kill-ring))
(setq savehist-autosave-interval 300)  ; 每 5 分钟自动保存一次
 
;; 记录光标位置,下次打开同一文件回到原处
(save-place-mode 1)

3.2 consult 提供的几个入口

consult 把”从一堆候选里选一个”的交互标准化了,配合 vertico 使用时体验最好(两者都在 包管理与 use-package 中介绍过安装方式):

命令作用
consult-recent-file从最近文件里选(相当于 recentf-open-files 的预览版)
consult-buffer统一的缓冲区切换入口,混合最近文件、书签与缓冲区
consult-find按路径查找文件(调用 find)
consult-project-buffer只在当前项目内选缓冲区
consult-line在当前缓冲区按行搜索,边输入边跳转
consult-imenu跳到当前文件的函数与变量
(use-package consult
  :ensure t
  :bind (("C-x b"   . consult-buffer)
         ("C-x C-r" . consult-recent-file)
         ("C-c f"   . consult-find)
         ("M-g l"   . consult-line)
         ("M-g i"   . consult-imenu)))

3.3 project.el:内置的项目管理

project.el 从 Emacs 28 起是内置的,前缀键是 C-x p。它在”项目根目录”的概念上工作:根目录由版本控制目录(.git 等)或标记文件识别。本仓库根目录有 .git,因此用 Emacs 打开仓库里任意文件后,C-x p 的一系列命令就以仓库根为范围。

键位命令说明
C-x p fproject-find-file在项目内按文件名打开(不需要输入完整路径)
C-x p pproject-switch-project切换到另一个项目
C-x p bproject-switch-to-buffer在项目内的缓冲区之间切换
C-x p dproject-find-dir打开项目内的目录(Dired)
C-x p Dproject-dired在项目根打开 Dired
C-x p gproject-find-regexp在项目内搜索正则
C-x p rproject-query-replace-regexp在项目内交互式替换
C-x p kproject-kill-buffers关掉属于该项目但不该关的缓冲区(按规则过滤)
C-x p vproject-vc-dir打开版本控制状态视图
C-x p cproject-compile在项目根执行编译
C-x p !project-shell-command在项目根执行 shell 命令
C-x p C-bproject-list-buffers列出项目缓冲区
;; 记住某个目录下的所有项目,之后 C-x p p 会有候选
(project-remember-projects-under "~/code/" t)
 
;; 关掉缓冲区时保留这些(默认保留能访问文件的缓冲区)
(setq project-kill-buffer-conditions
      '(buffer-file-name
        (major-mode . dired-mode)))

projectile 与 project.el 的取舍:projectile 出现更早、功能更多(项目类型自动识别、缓存、与 counsel 集成),代价是额外依赖与略慢的首次索引;project.el 是内置的,与 consult、xref、Magit 的配合已经足够。新配置建议先用 project.el,确实缺功能再引入 projectile。如果两者都用,可以关掉 projectile 的全局模式只用它的命令,避免键位冲突。

3.4 书签

bookmark.el 也是内置的,适合记住”散落在磁盘各处、又不属于任何项目”的文件与目录:

键位命令说明
C-x r mbookmark-set为当前位置建书签
C-x r bbookmark-jump跳到书签
C-x r lbookmark-bmenu-list打开书签列表(可编辑、可批量删除)
M-x bookmark-savebookmark-save立即保存书签文件

在 Org 里也可以直接把书签或文件写成链接:[[file:~/org/notes.org::*标题][说明]] 这种形式支持跳到文件中某个标题,比书签更细粒度。


四、全文搜索

4.1 ripgrep 与 consult 的组合

搜索的正确顺序是”先内容、后文件名”:记不住文件名时,用内容搜索最快。Emacs 里这条路径是 consult 加 ripgrep。

安装 ripgrep(rg):

# Debian 与 Ubuntu
$ sudo apt install ripgrep
 
# Arch Linux
$ sudo pacman -S ripgrep
 
# macOS(Homebrew)
$ brew install ripgrep
 
# Windows(winget 与 scoop 二选一)
> winget install BurntSushi.ripgrep.MSVC
> scoop install ripgrep
(use-package consult
  :ensure t
  :bind (("C-c s" . consult-ripgrep)      ; 在整个项目或目录里按内容搜索
         ("C-c S" . consult-grep)         ; 用 grep 搜索(不依赖 rg)
         ("C-c g" . consult-git-grep)     ; 只在 Git 跟踪的文件里搜索
         ("M-g l" . consult-line)))

consult-ripgrep 的工作方式是:在 minibuffer 里输入正则,候选列表实时显示匹配行与文件名,按 RET 跳到该行。几个实用细节:

  • 输入里用空格分词时,consult 会把它们当作多个必须同时匹配的模式(按顺序匹配),因此 defun consult-ripgrep 可以直接搜到定义处。
  • 前缀参数 C-u 可以改变搜索范围:C-u C-c s 会额外提示一个搜索目录,方便在非项目目录里搜。
  • consult-grepconsult-ripgrep 的区别只是底层命令不同,前者用 grep 后者用 rg;rg 更快,并且默认尊重 .gitignore

4.2 在大仓库里的性能

ripgrep 本身很快,但要让它在本仓库这种”几万个小 Markdown 文件”的场景里也快,需要几条设置:

(setq consult-ripgrep-args
      "rg --null --line-buffered --color=never --max-columns=1000 \
--path-separator / --smart-case --no-heading --with-filename \
--line-number --hidden --glob !.git/ --glob !node_modules/ \
--glob !_build/ --glob !.obsidian/")

--hidden 让 rg 也搜索点开头的目录,配合 --glob ! 排除项排除掉 .git_build.obsidian 这些不该搜的目录。这是在本仓库里用 C-c s 时最实际的一条优化:_build.git 目录里的内容通常既不需要搜也不需要看。

4.3 从搜索结果直接编辑

搜索到需要修改的地方时,不需要逐个文件打开:把结果变成一个可编辑的缓冲区即可。两条路径:

  • 用 consult 搜索后,通过 embark 的 embark-export 把候选导出成标准的 grep 缓冲区,然后在该缓冲区里进入 wgrep 模式编辑。
  • 直接用内置的 M-x grepM-x lgrep,结果同样进 grep 缓冲区。

wgrep 的用法(官方仓库:https://github.com/mhayashi1120/Emacs-wgrep):

键位命令说明
ewgrep-change-to-wgrep-mode在 grep 缓冲区里进入可编辑模式(ewgrep-enable-key 的默认值)
C-c C-eC-x C-swgrep-finish-edit把改动应用到文件
C-c C-kwgrep-abort-changes放弃全部改动
C-c C-dwgrep-mark-deletion把当前行标记为删除
C-c C-pwgrep-toggle-readonly-area切换只读区(文件名与行号部分)
C-c C-rwgrep-remove-change撤销当前行的改动
C-x C-qwgrep-exit退出 wgrep 模式
(use-package wgrep
  :ensure t
  :custom
  (wgrep-auto-save-buffer t))   ; 应用改动时自动保存被修改的文件

4.4 搜索与 LSP 的分工

两者解决的是不同问题,不该互相替代:

需求用什么原因
找某个字符串在仓库里出现的位置consult-ripgrep纯文本搜索,覆盖所有文件类型,包括文档与配置
找某个函数的定义与所有引用LSP(lsp-mode 或 eglot)与 xref理解语义,能区分同名符号与字符串里的巧合
在项目内做重命名LSP 的 rename 或 xref 与 wgrepLSP 更安全,wgrep 更适合非代码文件
查某个变量在注释与文档里被提到过吗consult-ripgrepLSP 索引不到注释与 Markdown

结论:写代码时用 LSP 找符号,读写文档与配置时用 ripgrep 找文本,两者都开着并不冲突。LSP 的配置见 补全与 LSP


五、笔记系统:org-roam、denote 与 deft

5.1 org-roam v2

org-roam 的核心概念只有两个:节点(node)与反向链接(backlink)。每个节点是一个 Org 标题,有一个稳定的唯一标识(ID);节点之间通过 ID 链接相连,org-roam 把这些链接建成一个数据库,从而能回答”哪些笔记引用了这条笔记”。

官方仓库:https://github.com/org-roam/org-roam,手册:https://www.orgroam.com/manual.html

依赖与安装要点:

  • 需要 Org 9.6 或更新版本。Emacs 30 自带的 Org 满足要求;如果用的是 Emacs 29 且自带 Org 版本较低,需要单独安装新版 Org。
  • 依赖 emacsqlhttps://github.com/magit/emacsql)与 magit-section,从 MELPA 安装 org-roam 时会被自动拉取。
  • 需要 SQLite 支持:一种是系统里有 sqlite3 可执行文件,另一种是使用 Emacs 29 起内置的 SQLite 能力(由 emacsql 的对应实现提供)。装包之前先确认 sqlite3 --version 能跑,是最省事的做法。
(use-package org-roam
  :ensure t
  :custom
  ;; 笔记库位置:用 file-truename 解析符号链接,否则符号链接会导致数据库路径错乱
  (org-roam-directory (file-truename "~/org/roam/"))
  ;; 数据库位置;默认在 org-roam-directory 下,放在同步盘之外更安全
  (org-roam-db-location "~/.emacs.d/org-roam.db")
  ;; 补全界面里显示标题与标签
  (org-roam-node-display-template
   (concat "${title:*} " (propertize "${tags:10}" 'face 'org-tag)))
  (org-roam-completion-everywhere t)   ; 在任意位置用补全插入节点链接
  :bind (("C-c n l" . org-roam-buffer-toggle)   ; 打开反向链接侧栏
         ("C-c n f" . org-roam-node-find)       ; 按标题查找节点
         ("C-c n i" . org-roam-node-insert)     ; 插入指向某节点的链接
         ("C-c n c" . org-roam-capture)         ; 新建节点
         ("C-c n g" . org-roam-graph)           ; 打开关系图
         ("C-c n j" . org-roam-dailies-capture-today)) ; 今天的日记
  :config
  ;; 保存文件时自动同步数据库,这是最省心的做法
  (org-roam-db-autosync-mode 1))

三个入口对应的动作:

命令键位用途
org-roam-node-findC-c n f在已有节点里查找并打开;输入新标题则新建
org-roam-node-insertC-c n i在当前笔记里插入一个指向其他节点的链接(ID 链接)
org-roam-captureC-c n c按模板新建节点,可以带上下文(例如从当前节点派生出子节点)

日记(dailies)是 org-roam 的另一个入口:org-roam-dailies-capture-today 为今天建一个节点,org-roam-dailies-goto-today 打开它,org-roam-dailies-capture-dateorg-roam-dailies-goto-date 用于指定日期。日记模板通过 org-roam-dailies-capture-templates 配置,常见做法是在日记里写当天的流水记录,然后在其他笔记里用 org-roam-node-insert 引用它们。

捕获模板的配置(这一段决定了”新建笔记时长什么样”):

(setq org-roam-capture-templates
      '(("d" "默认笔记" plain "%?"
         :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
                            "#+title: ${title}\n#+filetags: \n\n")
         :unnarrowed t)
        ("p" "永久笔记" plain
         "* 结论\n\n%?\n\n* 依据\n\n* 相关\n\n"
         :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
                            "#+title: ${title}\n#+filetags: permanent\n\n"))
        ("m" "会议记录" entry
         (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
                    "#+title: ${title}\n#+filetags: meeting\n\n* 参会人\n\n* 议题\n\n* 结论\n\n* 待办\n\n")
         :unnarrowed t)))

数据库构建与性能:

  • 首次使用需要执行 M-x org-roam-db-sync 建立数据库;org-roam-db-autosync-mode 之后会在保存时增量更新。
  • 数据库位置很重要。放在 org-roam-directory 里(默认行为)会让同步盘不断上传一个频繁变动的大文件,建议显式设到 ~/.emacs.d/ 或其他不同步的目录。
  • 笔记量到几千条之后,org-roam-node-find 的候选生成会变慢,可以通过精简 org-roam-node-display-template(模板越复杂,候选生成越慢)来缓解;如果笔记跨了完全不同的领域,也可以维护成两套彼此独立的 roam 目录与数据库,跨库检索时用 consult-ripgrep 兜底。
  • 数据库本身是可以重建的派生数据。换机器时不需要同步 .db 文件,同步笔记的 .org 文件,在新机器上跑一次 org-roam-db-sync 即可。

5.2 org-roam 与 Obsidian 双链接的关系(结合本仓库)

本仓库的全部文档用 Obsidian 的双链接组织,写法是路径式链接,且不带 .md 后缀:

[[npm|npm 教程]]
[[AI_Agent工具使用教程|AI Agent 工具]]
[[emacs教程/6扩展应用/01_内置浏览器EWW|内置浏览器 EWW]]
[[linux/README|Linux 教程]]

而 org-roam 生成的链接是 ID 式链接:

参见 [[id:9f2c1a3e-6b4d-4c8e-8f1a-2b3c4d5e6f70][另一条笔记]]

两者的差别不只是写法,而是”链接指向什么”这个根本问题:

维度Obsidian 路径链接org-roam ID 链接
链接内容相对仓库根的路径(可带别名)一个 UUID
目标定位路径与文件一一对应,文件名就是身份ID 存在文件内部的 :ID: 属性里
改名影响改名会让其他文件的链接失效(Obsidian 会自动更新,但外部工具不会)改文件名不影响链接,ID 不变
可读性人直接能看懂指向哪里必须靠工具解析
外部可解析性静态站点生成器与 GitHub 都能识别只有 org-roam 与 Org 能识别

因此结论很明确:不要把 org-roam 的 ID 链接写进本仓库的 Markdown 文档,反之也不要把 [[路径/文件名]] 交给 org-roam 的数据库去解析。原因有三个:

  1. org-roam 的节点模型建立在 Org 文件与 Org 标题上,Markdown 文件里的 [[路径]] 在它看来只是一段普通文本,建不出节点与反向链接。
  2. org-roam 的 ID 链接语法是 [[id:...][描述]],这在本仓库的静态站点渲染里不会被解析成链接,读者会看到一串 UUID。
  3. 本仓库的双链接是”路径即身份”,org-roam 是”ID 即身份”,两套身份体系混在一起,任何自动改名或自动补全工具都可能把对方的链接改坏。

如果确实想在同一个目录树里同时用 Obsidian 与本仓库的编辑流程,实践建议如下:

  • 目录分层隔离:把 org-roam 笔记放在 ~/org/roam/,本仓库文档放在仓库目录里;两边各自维护,需要互相引用时用普通 Markdown 链接或 Org 链接([[file:...]]),不要用对方的内部链接语法。
  • 不要在仓库里放 .db 文件,也不要让 org-roam-directory 指向仓库根目录。org-roam 扫描会把仓库里所有 Org 文件都当节点,几万篇文档会让数据库与补全都变得不可用。
  • 在 Emacs 里编辑本仓库文档时,用 Markdown 模式加纯文本工具(ripgrep、wgrep、Magit),不要开 org-roam,避免误把 Markdown 当作 Org 处理(.md.org 的标题语法、链接语法、缩进规则都不同)。
  • 如果要用 denote 管理仓库内的文档,注意 denote 会按自己的命名规则改名与插入 front matter,会破坏现有的双链接路径,因此同样不建议直接作用在仓库文档上。

一句话总结:本仓库的文档体系与 org-roam 是两套不同的知识组织方式,各自都很好用,但混用的成本高于收益;把它们分目录管理,需要打通时手工写普通链接。

5.3 denote:文件名即元数据

denote 的哲学与 org-roam 相反:不依赖数据库与 ID,元数据全部写在文件名里,任何文件管理器、任何搜索工具都能看懂。官方手册:https://protesilaos.com/emacs/denote,仓库:https://github.com/protesilaos/denote(GNU ELPA 上也有)。

命名格式是:

20240322T131856--some-title__topic1_topic2.org

拆开看:20240322T131856 是标识符(时间戳),-- 之后是标题,__ 之后是关键词,多个关键词用下划线连接,最后是扩展名。早期文档里出现过用 == 分隔标识符与标题的写法,那是旧版格式;以手册中的当前格式为准,配置时也不要去改这个分隔符。

;; denote 在 GNU ELPA 上,可直接安装
;; M-x package-install RET denote RET
 
(require 'denote)
 
;; 笔记目录;denote 支持多个目录(silo),这里先设一个
(setq denote-directory (expand-file-name "~/notes/"))
 
;; 新笔记的默认文件类型
(setq denote-file-type 'org)          ; 可选 org、md、txt
 
;; 创建或打开:输入已有笔记的标题就打开,输入新标题就新建
(keymap-set global-map "C-c n n" #'denote-open-or-create)
;; 插入指向其他笔记的链接
(keymap-set global-map "C-c n l" #'denote-link)
;; 改名(同时更新引用它的链接)
(keymap-set global-map "C-c n r" #'denote-rename-file)
 
;; 在 Dired 里按 denote 的方式显示,并支持批量改名
(with-eval-after-load 'dired
  (define-key dired-mode-map (kbd "C-c C-d") #'denote-dired-mode))
;; 在 Dired 里给一批文件批量改关键词:denote-dired-rename-marked-files-with-keywords

denote 与 org-roam 的对比:

维度denoteorg-roam
身份来源文件名里的时间戳标识符文件内部的 ID 属性
链接方式普通文件链接,链接文本里带标识符id: 链接
数据库不需要需要 SQLite 数据库
反向链接有(基于检索实现,规模大时较慢)有(基于数据库,速度快)
依赖emacsql、SQLite、magit-section、Org 9.6 以上
外部工具兼容文件名与链接都是纯文本,任何工具都能处理需要理解 Org 的 ID 机制
适合文件数量中等、看重可移植与低依赖笔记量大、需要复杂查询与关系图

5.4 deft:最轻量的纯文本检索

deft 只做一件事:在一个目录里按标题与内容快速搜索并打开文件(支持 Org 与 Markdown)。它没有链接、没有数据库、没有反向链接,因此特别适合”一堆散装笔记”的场景。

(use-package deft
  :ensure t
  :bind ("C-c n d" . deft)
  :custom
  (deft-directory "~/notes/")
  (deft-extensions '("org" "md" "txt"))
  (deft-recursive t)
  (deft-use-filename-as-title t)
  (deft-auto-save-interval 1.0))

5.5 三种笔记方案的取舍总结

  • 需要反向链接、关系图、复杂查询,且不介意数据库与依赖:选 org-roam。
  • 需要文件名自带元数据、目录可以直接用 Git 管理、换工具也能读:选 denote。
  • 只需要”快速找到并打开一个文本笔记”:选 deft。
  • 已经在用 Obsidian 维护仓库文档:不要为了统一而迁移,Markdown 加路径双链接的体系本身没有问题;Emacs 在这一体系里的角色是编辑器与搜索器(见下一节),不是笔记数据库。
graph TD
    A["捕捉:灵感、摘录、待读"] -->|"org-capture 或 denote 新建"| B["原始笔记"]
    B -->|"写自己的话,建立链接"| C["结构化笔记"]
    C -->|"org-roam-node-insert 或 denote-link"| D["笔记之间的链接"]
    D -->|"反向链接侧栏与关系图"| E["发现关联"]
    E -->|"org-agenda 或 dailies 回顾"| F["定期回顾"]
    F -->|"consult-ripgrep 全文检索"| G["复用与写作"]
    G -->|"新的问题"| A

六、与 RootStack 文档体系互操作

本仓库的文档是 Markdown 加 Obsidian 双链接,全部可以用 Emacs 编辑与检索。下面这套配置是”在本仓库里工作”的推荐起点。

6.1 markdown-mode 与文件识别

(use-package markdown-mode
  :ensure t
  :mode (("\\.md\\'" . markdown-mode)
         ("\\.markdown\\'" . markdown-mode))
  :custom
  ;; 用 GitHub 风格渲染,抑制标题下方的下划线式高亮
  (markdown-header-scaling t)
  (markdown-fontify-code-blocks-natively t)
  :hook ((markdown-mode . visual-line-mode)   ; 长段落自动换行
         (markdown-mode . variable-pitch-mode))) ; 正文用比例字体

markdown-mode 能识别标准 Markdown 链接 [文字](路径) 并用 C-c C-omarkdown-follow-link-at-point)跳转,但它不认识 Obsidian 的 [[路径|别名]] 双链接,因此需要一个自定义的跳转命令。下面两个函数是完整可用的版本,第一个负责取出光标处的双链接目标,第二个负责打开它,第三个提供一个补全函数,让输入 [[ 之后可以按文件名补全。

;;; ============ 处理 RootStack 的双链接 ============
 
(require 'subr-x)   ; 用到 string-trim
 
(defcustom my/rootstack-root "~/RootStack/"
  "RootStack 仓库根目录。双链接里的路径都相对于这个目录。"
  :type 'directory)
 
(defun my/rootstack-wikilink-at-point ()
  "返回光标所在 [[路径|别名]] 中的路径部分;不在双链接上时返回 nil。"
  (let ((origin (point))
        (case-fold-search nil)
        result)
    (save-excursion
      ;; 从行首到光标处找最后一个 [[...]]
      (when (re-search-backward "\\[\\[\\([^][|]+\\)\\(?:|[^][]*\\)?\\]\\]"
                                (line-beginning-position) t)
        ;; 只有光标确实落在这一对括号之内才算命中
        (when (and (<= (match-beginning 0) origin)
                   (<= origin (match-end 0)))
          (setq result (string-trim (match-string 1))))))
    result))
 
(defun my/rootstack-follow-wikilink ()
  "打开光标处双链接指向的仓库文件。"
  (interactive)
  (let* ((link (my/rootstack-wikilink-at-point))
         (root (expand-file-name my/rootstack-root))
         (base (and link (expand-file-name link root)))
         ;; 链接里不带 .md 后缀,依次尝试几种扩展名
         (file (and base
                    (or (and (file-exists-p (concat base ".md")) (concat base ".md"))
                        (and (file-exists-p base) base)))))
    (cond
     ((null link) (user-error "光标处没有双链接"))
     ((null file) (user-error "找不到双链接目标:%s" link))
     (t (find-file file)))))
 
;; 在 markdown-mode 里接管 C-c C-o,并在头行提示可用
(with-eval-after-load 'markdown-mode
  (keymap-set markdown-mode-map "C-c C-o" #'my/rootstack-follow-wikilink))

补全:输入 [[ 之后弹出仓库里所有 Markdown 文件的相对路径。

(defun my/rootstack-wikilink-capf ()
  "在 `[[' 之后补全仓库内的 Markdown 文件路径。"
  (when (looking-back "\\[\\[\\([^][|]*\\)" (line-beginning-position))
    (let* ((start (match-beginning 0))
           (end (match-end 0))
           (root (expand-file-name my/rootstack-root))
           (files (when (file-directory-p root)
                    (directory-files-recursively
                     root "\\.md\\'"
                     nil                                   ; 不返回目录本身
                     (lambda (dir)                          ; 跳过隐藏目录与构建产物
                       (not (string-match-p
                             "/\\(?:\\.git\\|\\.obsidian\\|_build\\|node_modules\\)/?\\'"
                             dir))))))
           (cands (mapcar (lambda (f)
                            (file-name-sans-extension
                             (file-relative-name f root)))
                          files)))
      (list start end cands :exclusive 'no))))
 
(add-hook 'markdown-mode-hook
          (lambda ()
            (add-hook 'completion-at-point-functions
                      #'my/rootstack-wikilink-capf nil t)))

上面这几段代码的要点:directory-files-recursively 的第四个参数是”是否进入该子目录”的判断函数,用它排除 .git_build 之后,候选列表才不会被构建产物淹没;补全返回的候选是去掉 .md 后缀的相对路径,正好是双链接里要写的形式。

6.2 在仓库里检索与提交

;;; ============ 在 RootStack 仓库里工作 ============
 
(defun my/rootstack-search ()
  "在本仓库里按内容搜索(rg 会尊重排除规则,这里显式排除构建产物)。"
  (interactive)
  (let ((default-directory (expand-file-name my/rootstack-root)))
    (consult-ripgrep)))
 
(defun my/rootstack-open-index ()
  "打开仓库的根索引文件。"
  (interactive)
  (find-file (expand-file-name "README.md" my/rootstack-root)))
 
;; 打开仓库里任意文件时,自动把搜索限定在仓库范围
(with-eval-after-load 'consult
  (keymap-set global-map "C-c C-s" #'my/rootstack-search))

提交用 Magit:C-x g 打开状态视图(M-x magit-status),s 暂存、c c 提交、P u 推送。批量改名的提交特别适合在 Magit 里复查,因为状态视图会清楚列出新增、删除与重命名的文件。详细用法见 Git 与 Magit

(keymap-set global-map "C-x g" #'magit-status)

七、剪贴板与 kill-ring

Emacs 内部的复制粘贴走 kill-ring(一个文本历史列表),与系统剪贴板之间通过”选择(selection)“机制交互。

变量默认值含义
select-enable-clipboardt是否与系统 CLIPBOARD 交互(复制内容进入系统剪贴板)
select-enable-primaryt(图形环境)是否与 X11 的 PRIMARY 交互(选中即复制)
interprogram-cut-function图形环境下已设置把 kill-ring 最新内容送进系统剪贴板的钩子
interprogram-paste-function图形环境下已设置从系统剪贴板取内容的钩子
kill-ring-max60kill-ring 保留的条目数

CLIPBOARD 与 PRIMARY 的区别是 X11 的历史包袱:CLIPBOARD 是显式复制(C-c)的内容,PRIMARY 是”鼠标选中”的内容,中键粘贴用 PRIMARY。在 macOS 与 Windows 上只有 CLIPBOARD 的概念,PRIMARY 不生效,因此不要依赖”选中即复制”的行为。

;; 让 kill-ring 更长,并且被 savehist 持久化(见 3.1 节)
(setq kill-ring-max 200)
 
;; 图形环境下保持与系统剪贴板同步
(setq select-enable-clipboard t)
;; 非 X11 平台上这一项没有意义,显式关闭可以避免困惑
(setq select-enable-primary
      (eq window-system 'x))
 
;; 查看与选择 kill-ring 历史:consult 提供 consult-yank-pop
(with-eval-after-load 'consult
  ;; 先 C-y 粘贴,再 M-y 在历史里上下选择
  (keymap-set global-map "M-y" #'consult-yank-pop))

consult-yank-pop 的用法是:先用 C-y 粘贴一次,然后按 M-y 打开候选列表,在 kill-ring 历史里选择要替换成哪一条。这样既保留了熟悉的 C-y,又能访问上百条历史,比装额外的剪贴板管理器更省事。


八、文件模板与自动插入

8.1 auto-insert

auto-insert 是内置功能,在新建文件时按文件名模式自动插入一段模板,最典型的用途是给新建的源码文件加文件头。

(require 'auto-insert)
(auto-insert-mode 1)          ; 全局生效
 
;; 询问策略:'function 表示只有手动 M-x auto-insert 时才问;
;; 设为 nil 表示从不询问,直接插入
(setq auto-insert-query nil)
 
;; 模板文件存放目录;找不到模板文件时 auto-insert 会报错提示
(setq auto-insert-directory "~/.emacs.d/templates/")
 
;; 为 Markdown 指定模板文件。字符串形式是"模板文件名",
;; 它的内容会被原样插入新缓冲区,不做任何替换
(define-auto-insert "\\.md\\'" "rootstack-doc.md")
 
;; 需要动态内容(文件名、日期)时,action 要写成 skeleton,
;; 而不是文件名。skeleton 里的 _ 表示插入后光标停在这里
(define-auto-insert "\\.el\\'"
  '(";;; " (file-name-nondirectory buffer-file-name) " --- " _ "\n"
    ";;; Commentary:\n;;\n"
    ";;; Code:\n\n"
    "\n(provide '" (file-name-base buffer-file-name) ")\n"))
 
;; 为某些文件关掉自动插入:把 action 设成 ignore(一个什么都不做的函数)
(setq auto-insert-alist
      (cons '(("\\.\\(?:org\\|txt\\)\\'" . "不要自动插入") . ignore)
            auto-insert-alist))

~/.emacs.d/templates/rootstack-doc.md 的内容就是新建 Markdown 文件时得到的内容,因为模板文件是被 insert-file-contents 原样插入的:

#
 
> 一句话说明本篇解决什么问题、适合谁读。
 
---
 
## 一、
 
---
 
## 相关章节
 
- [[index|总索引]]

这里有一个容易误解的地方:auto-insert 的模板文件不做占位符替换。想让模板里出现文件名或日期,只有两条路——把 action 写成上面第二种 skeleton 形式,或者把 action 写成一个函数(例如 (lambda () (insert (format-time-string "# %Y-%m-%d\n"))))。alist 里那条 '(("\\.\\(?:org\\|txt\\)\\'" . "不要自动插入") . ignore) 之所以用 . ignore,是因为 ignore 是一个函数,调用它什么都不做,等于为该类文件设了”空模板”。

8.2 tempel:通用模板

tempel 用 GNU ELPA 分发(https://elpa.gnu.org/packages/tempel.html),模板写在 user-emacs-directory 下的 templates 文件里,语法沿用 Emacs 古老的 Tempo 库。

(use-package tempel
  :ensure t
  :bind (("M-+" . tempel-complete)   ; 补全并展开模板
         ("M-*" . tempel-insert))    ; 按名字选择模板插入
  :init
  (defun my/tempel-setup-capf ()
    "把 tempel 的 Capf 加到补全函数列表最前面。"
    (setq-local completion-at-point-functions
                (cons #'tempel-expand completion-at-point-functions)))
  (add-hook 'text-mode-hook #'my/tempel-setup-capf)
  (add-hook 'prog-mode-hook #'my/tempel-setup-capf)
  (add-hook 'conf-mode-hook #'my/tempel-setup-capf))

~/.emacs.d/templates 的内容(第一行是适用的主模式,之后每行是一个模板):

;; ~/.emacs.d/templates
fundamental-mode
(today (format-time-string "%Y-%m-%d")
       :ann "今天的日期")
 
text-mode
(note "#+title: " p "\n\n" r>)
 
prog-mode
(todo (if (derived-mode-p 'emacs-lisp-mode) ";; " comment-start) "TODO " p)
(fixme (if (derived-mode-p 'emacs-lisp-mode) ";; " comment-start) "FIXME " p)
 
markdown-mode
(doc "# " p "\n\n> " p "\n\n---\n\n## 一、\n\n")
(link "[[" p "|" p "]]")

模板语法里 p 表示插入后把光标停在这里,r> 表示插入当前选区内容,n> 表示换行并重新缩进,(s 变量) 表示一个会被记住的字段。

8.3 yasnippet 与 skeleton

yasnippet 是更老牌的模板系统,模板文件是独立的 snippet 文件,生态里现成的模板更多(https://github.com/joaotavora/yasnippet):

(use-package yasnippet
  :ensure t
  :custom
  (yas-snippet-dirs '("~/.emacs.d/snippets"))
  :config
  (yas-global-mode 1))

skeleton 是内置的模板机制,适合”只在配置里定义一个模板、不想引入包”的场景:

(define-skeleton my/insert-elisp-header
  "插入符合 Emacs 规范的文件头。"
  nil
  ";;; " (file-name-nondirectory (buffer-file-name)) " --- " _ "\n"
  ";;; Commentary:\n;;\n"
  ";;; Code:\n\n"
  "\n(provide '" (file-name-base (buffer-file-name)) ")\n"
  ";;; " (file-name-nondirectory (buffer-file-name)) " ends here\n")
 
;; 在空的 Elisp 文件里按 C-c h 插入
(keymap-set emacs-lisp-mode-map "C-c h" #'my/insert-elisp-header)

三者的取舍:只需要少量固定模板就用内置的 skeleton;想用语法简洁的模板文件就用 tempel;需要大量现成模板、或要跟团队共享 snippet 就用 yasnippet。三者可以共存,但模板触发键不要重叠。


九、云盘与同步目录的注意事项

把笔记目录放进 Dropbox、OneDrive、坚果云之类的同步盘是常见做法,但有几类文件不适合被同步:

  • SQLite 数据库(org-roam 的 .db):同步客户端在文件被写入时上传,可能传上去一个正在写入的半成品;两边同时运行 Emacs 时冲突几乎必然发生。做法是把 org-roam-db-location 设到同步目录之外。
  • Git 仓库的 .git 目录:同步客户端与 Git 同时写会造成对象文件冲突。仓库用 Git 同步,不要同时用云盘同步。
  • Emacs 的自动保存文件(#file#)与锁文件(.#file):它们的存在会影响其他工具的解析,建议配置 auto-save-file-name-transformscreate-lockfiles 把它们移出目录,或者在同步盘目录里关掉锁文件。
;; 把自动保存文件集中放到临时目录
(setq auto-save-file-name-transforms
      `((".*" ,(expand-file-name "auto-save/" user-emacs-directory) t)))
 
;; 同步盘里容易产生锁文件冲突;只在本地目录使用锁文件
(setq create-lockfiles t)
 
;; 备份文件也集中存放,避免把 ~ 后缀的文件同步出去
(setq backup-directory-alist
      `(("." . ,(expand-file-name "backups/" user-emacs-directory))))

符号链接:如果笔记目录是一个符号链接(例如 ~/notes 指向云盘里的真实目录),org-roam 需要 (file-truename ...) 解析真实路径才能正确建库;denote 与 deft 一般不受影响,但 denote-directory 也建议写解析后的路径。

Windows 的路径长度限制:传统 Win32 API 的路径上限是 260 个字符,而 org-roam 或 denote 生成的文件名(时间戳加标题加关键词)很容易变得很长,再加上云盘的深层目录,容易触顶。规避办法有三个:把同步目录设在盘符根下的浅层路径(如 D:\notes)、缩短笔记标题(denote 的标题在文件名里会被截断,可用 denote-file-name-slug-functions 调整)、或者在 Windows 上开启长路径支持(组策略里的”启用 Win32 长路径”选项,需要程序本身声明支持)。


十、完整配置块

下面这段把 Dired 增强、文件树、搜索、笔记与键位整合在一起,逐段注释了取舍。没有用到的部分可以整段删掉。

;;; ============ 一、Dired:内置能力优先 ============
 
;; 打开 Dired 时隐藏细节列,屏幕更清爽;需要看权限时按 (
(add-hook 'dired-mode-hook #'dired-hide-details-mode)
 
;; 复制文件时保留目标目录的标记状态,跨目录整理时更好用
(setq dired-dwim-target t)          ; 两个窗口都开着 Dired 时默认目标是另一个窗口的目录
(setq dired-recursive-copies 'always)
(setq dired-recursive-deletes 'top) ; 递归删除时只在顶层确认一次
(setq dired-kill-when-opening-new-dired-buffer t) ; 进入子目录时复用同一个缓冲区
(setq dired-listing-switches "-alh --group-directories-first") ; 目录排在前面(GNU ls 专有选项)
(setq dired-auto-revert-buffer t)   ; 重新进入目录时自动刷新
 
;; 让 Dired 里对常见文件按 & 时补出合适的命令
(require 'dired-x)
(setq dired-guess-shell-alist-user
      '(("\\.pdf\\'" "zathura")
        ("\\.mp4\\'" "mpv")))
 
;; 批量改名与查看的入口
(with-eval-after-load 'dired
  (keymap-set dired-mode-map "C-c C-r" #'wdired-change-to-wdired-mode))
 
;;; ---- 可选:dired-subtree 与外观增强 ----
(use-package dired-subtree
  :ensure t
  :after dired
  :bind (:map dired-mode-map
              ("TAB" . dired-subtree-toggle)
              ("<backtab>" . dired-subtree-cycle)))
 
(use-package diredfl
  :ensure t
  :hook (dired-mode . diredfl-mode))
 
;;; ============ 二、文件树:二选一 ============
;; 方案 A:dirvish(增强 Dired,不改变键位习惯)
(use-package dirvish
  :ensure t
  :init
  (dirvish-override-dired-mode)
  :custom
  (dirvish-attributes '(vc-state subtree-state file-time file-size))
  (dirvish-default-layout '(1 0.11 0.55))
  :bind (("C-c d" . dirvish)
         ("C-c f" . dirvish-fd)))
 
;; 方案 B:treemacs(独立侧边栏,适合多项目)
;; 两者可以共存,但不要同时占用同一个键
;; (use-package treemacs
;;   :ensure t
;;   :bind ("C-c t" . treemacs)
;;   :config (treemacs-follow-mode 1) (treemacs-filewatch-mode 1))
 
;;; ============ 三、快速打开与搜索 ============
(recentf-mode 1)
(setq recentf-max-saved-items 300)
(savehist-mode 1)
(save-place-mode 1)
(setq savehist-additional-variables '(search-ring regexp-search-ring kill-ring))
 
(use-package consult
  :ensure t
  :custom
  ;; 排除构建产物与元数据目录,避免搜索结果被噪音淹没
  (consult-ripgrep-args
   "rg --null --line-buffered --color=never --max-columns=1000 \
--path-separator / --smart-case --no-heading --with-filename \
--line-number --hidden --glob !.git/ --glob !_build/ \
--glob !.obsidian/ --glob !node_modules/")
  :bind (("C-x b"   . consult-buffer)
         ("C-x C-r" . consult-recent-file)
         ("C-c s"   . consult-ripgrep)
         ("C-c S"   . consult-grep)
         ("M-g l"   . consult-line)
         ("M-y"     . consult-yank-pop)))
 
(use-package wgrep
  :ensure t
  :custom
  (wgrep-auto-save-buffer t))
 
;;; ============ 四、笔记系统:二选一 ============
;; 方案 A:org-roam(需要 emacsql 与 SQLite)
(use-package org-roam
  :ensure t
  :custom
  (org-roam-directory (file-truename "~/org/roam/"))
  (org-roam-db-location "~/.emacs.d/org-roam.db")   ; 不要放进同步盘
  (org-roam-completion-everywhere t)
  :bind (("C-c n l" . org-roam-buffer-toggle)
         ("C-c n f" . org-roam-node-find)
         ("C-c n i" . org-roam-node-insert)
         ("C-c n c" . org-roam-capture)
         ("C-c n j" . org-roam-dailies-capture-today))
  :config
  (org-roam-db-autosync-mode 1))
 
;; 方案 B:denote(无数据库,元数据写在文件名里)
;; (use-package denote :ensure t
;;   :custom (denote-directory (expand-file-name "~/notes/"))
;;   :bind (("C-c n n" . denote-open-or-create)
;;          ("C-c n l" . denote-link)
;;          ("C-c n r" . denote-rename-file)))
 
;;; ============ 五、项目与文档 ============
;; project.el 的内置前缀键就是 C-x p:C-x p f 打开项目内文件,C-x p g 在项目内搜索
(keymap-set global-map "C-x g" #'magit-status)
 
;; 在本仓库里编辑 Markdown:见第六节的完整代码
(use-package markdown-mode
  :ensure t
  :mode ("\\.md\\'" . markdown-mode)
  :hook ((markdown-mode . visual-line-mode)))
 
;;; ============ 六、自动保存与备份的位置 ============
(setq backup-directory-alist
      `(("." . ,(expand-file-name "backups/" user-emacs-directory))))
(setq auto-save-file-name-transforms
      `((".*" ,(expand-file-name "auto-save/" user-emacs-directory) t)))
(setq backup-by-copying t)          ; 保留符号链接与硬链接
(setq delete-old-versions t)
(setq kept-new-versions 6)
(setq kept-old-versions 2)

取舍说明:这套配置刻意避开了”装一堆增强包”的路线。Dired 本身能覆盖八成的文件管理需求,所以只加了 dired-subtree 与 diredfl 两个小而稳定的包;文件树只选一个;搜索统一走 consult;笔记系统按是否需要数据库二选一。配置越长越容易在升级时出问题,而本篇涉及的功能都是长期每天要用的,稳定比花哨重要。


十一、常见问题

11.1 Dired 打开大目录很慢

先看目录里有多少文件:几万个文件的目录,任何文件管理器都会慢。可行的缓解手段是关掉细节列(()、关掉 dired-x 的目录体积统计(dired-omit-size-limit)、以及避免在远程目录里打开大目录。如果目录确实需要频繁浏览,用 dired-listing-switches 加上 --group-directories-first 并去掉 -h 之外的修饰,作用有限但聊胜于无。

11.2 wdired 提交后文件名出错

最常见的原因是正则匹配到了行的其他部分(权限、大小、日期列)。解决办法是在替换时把匹配写得更具体,例如匹配 IMG_ 这样只在文件名里出现的字符串。另一个原因是同一目录里改出了重名文件,wdired 会报错并拒绝提交,这时按 C-c C-k 放弃,改好之后重来。

11.3 org-roam 数据库报错或查询为空

按顺序排查:确认 org-roam-directory 存在且用 file-truename 解析过;确认 sqlite3 --version 能正常运行;执行 M-x org-roam-db-sync 看报错信息;如果之前换过笔记目录,旧的 .db 里还留着旧路径,删掉数据库文件重新同步即可。数据库是派生数据,重建没有损失。

11.4 consult-ripgrep 搜不到应有的结果

三种原因:rg 不在 exec-path 里(在 Emacs 里执行 M-x shell-command RET which rg RET 验证);搜索范围被 .gitignore 排除(rg 默认尊重它,加 --no-ignore 可以绕过);自定义的 consult-ripgrep-args--glob ! 规则写得太宽,把目标目录也排除了。第三点在改造过参数之后最容易发生。

11.5 文件名在 Dired 里显示为转义形式

Dired 的文件名来自 ls 的输出,Emacs 只是把 dired-listing-switches(默认 -al)原样传给 ls。如果系统里的 ls 或某个包装脚本给这个变量加上了 -b-Q--quoting-style=escape 之类的选项,含空格与中文的文件名就会显示成转义形式。处理办法是确认并改写这个变量:

;; 默认值;不要加入 -b 或 -Q 之类的转义选项
(setq dired-listing-switches "-al")
 
;; 需要目录排在前面时,Linux 上可以加 GNU coreutils 的选项
;; macOS 的 BSD ls 不认识 --group-directories-first,需要装 coreutils 后用 gls
(setq dired-listing-switches "-alh --group-directories-first")

如果只是显示看着别扭、功能正常,也可以不处理:转义只是外观,打开、复制、重命名都不受影响。


小结

  • Dired 是内置的完整文件管理器:!dired-do-shell-command*? 替换、% 前缀的正则批量操作、i 插入子目录、wdired 编辑文件名,这四点掌握之后就不需要图形文件管理器了。
  • 文件树只在”看全局结构”时有用;打开文件应该走 C-x p fconsult-ripgrep
  • org-roam 与 denote 是两种笔记哲学(ID 与文件名),本仓库的 Obsidian 双链接属于第三种(路径即身份);三者不要混用,分目录管理最省心。

相关章节