Emacs: helm-cmd-t 显示文件完整路径

5节中的 diff 已经被 merge 到了官方 repo, 见 pull request 11. 所以这篇文章没什么用处了, 对 elisp 感兴趣的可以读读第3节.

helm-cmd-t 是神秘的 emacs 牛人 Le Wang 写的一个 helm 插件, 用来快速匹配一个 repo 里的文件. 但是我用的时候遇到个问题, helm-cmd-t 的结果列表只显示文件名, 而不显示其路径, 当一个 repo 里有重名的文件的时候, 没法分辨.

1 最终效果

我当前的 buffer 是 elpa 的 README, 按 M-x helm-cmd-t (我把这个命令绑定到了 Super+T, Super 就是大部分键盘上的 Windows 键), 然后输入正则表达式: js2.*e

emacs-helm-cmd-t-show-full-path.png

(我平时大部分时间用的 color-theme 是 zenburn, 跟我博客的配色太接近, 所以截图的时候特地换成了 soloarized-light)

截图中显示了 repo 里跟输入的表达式相匹配的文件的相对路径, 所以不会有因文件名相同而无法辨认的问题.

2 配置 emacs

只要把运行下面一行代码, 然后把它放到 init.el 里就可以了

(setq helm-ff-transformer-show-only-basename nil)

helm-ff-transformer-show-only-basename 这个变量控制 helm 中是否只显示文件名.

3 hack 过程

我跟 helm-cmd-t 相关的配置是:

(add-to-list 'load-path "~/hack/el/helm-cmd-t")
(require 'helm-cmd-t)
(setq helm-ff-lynx-style-map nil
      helm-input-idle-delay 0.1
      helm-idle-delay 0.1)
(global-set-key (kbd "s-t") 'helm-cmd-t)

这里面应该没有和路径显示相关的变量.

C-h v helm-cmd-t 补全的变量里, 好像也没有相关的.

直接看代码吧.

C-h f helm-cmd-t

helm-cmd-t is an interactive Lisp function in `helm-cmd-t.el'.

It is bound to s-t.

(helm-cmd-t &optional ARG)

Choose file from current repo.

With prefix arg C-u, run `helm-cmd-t-repos'.

点进 helm-cmd-t.el:

(defun helm-cmd-t (&optional arg)
  "Choose file from current repo.

With prefix arg C-u, run `helm-cmd-t-repos'.
"
  (interactive "P")
  (if (consp arg)
      (call-interactively 'helm-cmd-t-repos)
    (let ((root-data (helm-cmd-t-root-data)))
      (if root-data
          (helm :sources (helm-cmd-t-get-create-source root-data)
                :candidate-number-limit 20
                :buffer "*helm-cmd-t:*")
        (error "No repository for %s" default-directory)))))

感觉 :sources 对应的 (helm-cmd-t-get-create-source root-data) 是关键, 而 root-data 是 (helm-cmd-t-root-data) 的返回值. 验证一下, 在一个 repo 中的文件中按 M-: (helm-cmd-t-root-data), 得到 (“git” . “~/vagrant/bellroy/bellroy-magento”), 而 (helm-cmd-t-get-create-source (helm-cmd-t-root-data)) 返回一堆东西, 看不太懂.

搜一下 helm-cmd-t-get-create-source 的定义:

(defun helm-cmd-t-get-create-source (repo-root-data &optional skeleton)
  "Get cached source or create new one.
SKELETON is used to ensure a repo is listed without doing any
extra work to laod it. This can be used to ensure the 'current'
repo is always listed when selecting repos."
  (let* ((repo-root (cdr repo-root-data))
         (repo-type (car repo-root-data))
         (source-buffer-name (helm-cmd-t-get-source-buffer-name repo-root))
         (candidate-buffer (get-buffer-create source-buffer-name))
         (data (buffer-local-value 'helm-cmd-t-data candidate-buffer))
         (my-source (when (cdr (assq 'cache-p data))
                        (cdr (assq 'helm-source data)))))
    (or my-source
        (with-current-buffer candidate-buffer
          (erase-buffer)
          (setq default-directory (file-name-as-directory repo-root))
          (unless skeleton
           (helm-cmd-t-insert-listing repo-type repo-root))
          (setq my-source `((name . ,(format "[%s]" (abbreviate-file-name repo-root)))
                            (header-name . (lambda (_)
                                             (helm-cmd-t-format-title ,candidate-buffer)))
                            (init . (lambda ()
                                      (helm-candidate-buffer ,candidate-buffer)))
                            (candidates-in-buffer)
                            (keymap . ,helm-generic-files-map)
                            (match helm-files-match-only-basename)
                            (filtered-candidate-transformer . helm-cmd-t-transform-candidates)
                            (action-transformer helm-transform-file-load-el)
                            (action . ,(cdr (helm-get-actions-from-type helm-source-locate)))
                            ;; not for helm, but for lookup if needed
                            (candidate-buffer . ,candidate-buffer)))
          (let ((lines (count-lines (point-min) (point-max))))
            (set (make-local-variable 'helm-cmd-t-data)
                 (list (cons 'helm-source my-source)
                       (cons 'repo-root repo-root)
                       (cons 'repo-type repo-type)
                       (cons 'time-stamp (float-time))
                       (cons 'lines lines)
                       (cons 'cache-p (if skeleton nil
                                        (helm-cmd-t-cache-p lines repo-type repo-root))))))
          my-source))))

有点儿复杂, 读了一下, 关键应该是:

(setq my-source `((name . ,(format "[%s]" (abbreviate-file-name repo-root)))
                            (header-name . (lambda (_)
                                             (helm-cmd-t-format-title ,candidate-buffer)))
                            (init . (lambda ()
                                      (helm-candidate-buffer ,candidate-buffer)))
                            (candidates-in-buffer)
                            (keymap . ,helm-generic-files-map)
                            (match helm-files-match-only-basename)
                            (filtered-candidate-transformer . helm-cmd-t-transform-candidates)
                            (action-transformer helm-transform-file-load-el)
                            (action . ,(cdr (helm-get-actions-from-type helm-source-locate)))
                            ;; not for helm, but for lookup if needed
                            (candidate-buffer . ,candidate-buffer)))

尝试了一下, 看不太明白, 算了, 既然这个插件是利用版本控制系统, 那么列出一个 git 项目的文件的时候, 应该会用到一些 git 命令. projectile-ack git 搜一下, 找到一个变量:

(defvar helm-cmd-t-repo-types
  `(("git"         ".git"           "cd %d && git --no-pager ls-files --full-name")
    ("hg"          ".hg"            "cd %d && hg manifest")
    ("bzr"         ".bzr"           "cd %d && bzr ls --versioned")
    ("dir-locals"  ".dir-locals.el" helm-cmd-t-get-find)
    (""            ""               helm-cmd-t-get-find))
  "root types supported.
this is an alist of (type cookie format-string).

\"%d\" is replaced with the project root in the format-string.

format string can also be symbol that takes:

    repo-root

as its parameter. ")

其中 git --no-pager ls-files --full-name 看上去比较像我要找的东西, 随便找个 git 的项目, 运行一下这个命令, 果然是列出了很多文件. 但是其列出的是完整的相对路径, 而不是只有文件名. 这是怎么回事儿? 难道返回的文件名在某个地方被改动了? 搜一下这个变量.

发现在两个地方用到了, 一个是 helm-cmd-t-get-repo-root, 不像, 另一个:

(defun helm-cmd-t-insert-listing (repo-type repo-root)
  (let ((cmd (nth 2 (assoc repo-type helm-cmd-t-repo-types))))
    (if (functionp cmd)
        (funcall cmd repo-root)
      (shell-command (format-spec cmd (format-spec-make ?d repo-root)) t))))

这个看上去比较接近, 但是好像没有修改文件名的代码, 查查它在哪儿被用到了, 又回到了 helm-cmd-t-get-create-source:

(unless skeleton
           (helm-cmd-t-insert-listing repo-type repo-root))

随便找个空的 buffer, 把 (helm-cmd-t-insert-listing “git” “~/vagrant/bellroy/bellroy-magento”) 粘贴进去, 然后 C-x C-e 运行之, 所有文件就被列在了这个 buffer 中:

(helm-cmd-t-insert-listing "git" "~/vagrant/bellroy/bellroy-magento")
.gitattributes
.gitignore
.gitmodules
.htaccess
.htaccess.sample
GOOGLE_SEARCH.md
LICENSE.html
LICENSE.txt
LICENSE_AFL.txt
README.md
RELEASE_NOTES.txt
Rakefile
api.php
app/.htaccess
app/Mage.php
app/code/community/AW/Blog/Block/Blog.php
app/code/community/AW/Blog/Block/Cat.php
app/code/community/AW/Blog/Block/Last.php
app/code/community/AW/Blog/Block/Manage/Blog.php
app/code/community/AW/Blog/Block/Manage/Blog/Edit.php
app/code/community/AW/Blog/Block/Manage/Blog/Edit/Form.php
app/code/community/AW/Blog/Block/Manage/Blog/Edit/Tab/Form.php
app/code/community/AW/Blog/Block/Manage/Blog/Edit/Tab/Options.php
app/code/community/AW/Blog/Block/Manage/Blog/Edit/Tab/Related.php
app/code/community/AW/Blog/Block/Manage/Blog/Edit/Tabs.php
...

可这跟我在 helm-cmd-t 里看到的不一样啊… 说明修改是在这个后面的. 这几句都比较可疑:

(match helm-files-match-only-basename)
(filtered-candidate-transformer . helm-cmd-t-transform-candidates)
(action-transformer helm-transform-file-load-el)

每个都浏览一下, 看到 helm-cmd-t-transform-candidates:

(defun helm-cmd-t-transform-candidates (candidates source)
  "convert each candidate to cons of (disp . real)"
  (loop with root = (cdr (assq 'repo-root
                               (buffer-local-value 'helm-cmd-t-data
                                                   (helm-candidate-buffer))))
        for i in candidates
        for abs = (expand-file-name i root)
        for disp = (if (and helm-ff-transformer-show-only-basename
                            (not (helm-dir-is-dot i)))
                       (helm-basename i)
                     i)
        collect (cons (propertize disp 'face 'helm-ff-file) abs)))

里面的 helm-ff-transformer-show-only-basename 非常非常可疑, C-h v helm-ff-transformer-show-only-basename:

helm-ff-transformer-show-only-basename is a variable defined in `helm-files.el'.
Its value is t

Documentation:
Show only basename of candidates in `helm-find-files'.
This can be toggled at anytime from `helm-find-files' with C-].

看描述就是我要找的.

看了一下我的 init.el, 没有动过 helm-ff-transformer-show-only-basename, 有可能是 helm-cmd-t 改的, 在其 repo 里搜一下, 却只有上面那个 helm-cmd-t-transform-candidates 函数用到了, 不管了, 改一下试试:

(setq helm-ff-transformer-show-only-basename nil)

成了!

但不知道是否有隐患.

4 总结

helm-cmd-t 是建立在 helm 这个平台上的, 我在最开始的时候, 应该看看 helm 里面有没有变量控制文件名的显示格式的, 但一路看 elisp 代码, 增长点儿经验, 焉知非福.

5 思考

为什么 helm-cmd-t 要考虑 helm-ff-transformer-show-only-basename 这个变量呢? git 命令返回的已经是文件名的”正确”显示方式了.

可否做如下修改呢?

--- a/helm-cmd-t.el
+++ b/helm-cmd-t.el
@@ -276,10 +276,7 @@ specified, then it is used to construct the root-data. "
                                                    (helm-candidate-buffer))))
         for i in candidates
         for abs = (expand-file-name i root)
-        for disp = (if (and helm-ff-transformer-show-only-basename
-                            (not (helm-dir-is-dot i)))
-                       (helm-basename i)
-                     i)
+        for disp = i
         collect (cons (propertize disp 'face 'helm-ff-file) abs)))

 (defun helm-cmd-t-cache-p (line-count repo-type repo-root)
@@ -511,4 +508,3 @@ based on system type.

 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; helm-cmd-t.el ends here
-

另外, 这种匹配跟 sublime text 式的模糊匹配, 还差着不少距离, 有空试试 https://github.com/lewang/flx

Leave a Reply