🦍

Vim上でGitHubのファイルツリーを表示する話

2020/12/09に公開

初めに

先日、gh.vimにファイルツリー機能を実装しました。
Vim上でリポジトリのファイルツリーを見ることができる機能です。
その周りの話をしていきます。

やり方

:30vnew gh://:owner/:repo/:branch|:tree_shaを実行すると、ファイルツリーを開けます。
:repoの後はブランチ名、もしくはコミットハッシュを指定できます。
また、gheでカーソル上にあるファイルの中身を見ることができます。

実装

GitHubにはTreesAPIが用意されていて、それをたたくとファイルとディレクトリの情報を取得できます。

しかし、このレスポンスのではデータ構造が入れ子になっておらず、pathにリポジトリのルートディレクトリからのパス情報などが配列になっています。
たとえば次のディレクトリ構成だった場合

.
├── README.md
├── doc
│   └── gh.txt
└── plugin
    └── gh.vim

これが次のようなレスポンスになります。

{
  "sha": "xxx",
  "url": "xxx",
  "tree": [
    {
      "path": "README.md",
      ...
    },
    {
      "path": "doc",
      ...
    },
    {
      "path": "doc/gh.txt",
      ...
    },
    {
      "path": "plugin",
      ...
    }
    {
      "path": "plugin/gh.vim",
      ...
    }
  ]
}

ツリーを表現するにはフラットなデータ構造ではなく、ネストしたデータ構造のが都合よいので、これらのpathを元に次のようなデータ構造に変換する必要があります。

{
  "name": "gh.vim",
  "path": "gh.vim",
  "children": [
    {
      "name": "README.md",
      "path": "gh.vim/README.md"
    },
    {
      "name": "doc",
      "path": "gh.vim/doc",
      "children": [
        {
          "name": "gh.txt",
          "path": "gh.vim/doc/gh.txt"
        }
      ]
    },
    {
      "name": "plugin",
      "path": "gh.vim/plugin",
      "children": [
        {
          "name": "gh.vim",
          "path": "gh.vim/plugin/gh.vim"
        }
      ]
    }
  ]
}

gh.vimではs:make_nodeという関数で、1ファイルずつ、上記のデータ構造を構築しています。

function! s:make_node(tree, file) abort
  let paths = split(a:file.path, '/')
  let parent_path = join(paths[:-2], "/")
  let tree = a:tree
  let item = {
        \ 'name': a:file.type is# 'tree' ? paths[-1] .. '/' : paths[-1],
        \ 'path': a:file.path,
        \ 'info': a:file,
        \ 'markable': 1,
        \ }

  if a:file.type is# 'tree'
    let item['children'] = []
    let item['state'] = 'close'
  endif

  if has_key(b:tree_node_cache, parent_path)
    call add(b:tree_node_cache[parent_path], item)
    return
  endif

  if exists('tree.children')
    for node in tree.children
      call s:make_node(node, a:file)
    endfor
  endif

  if tree.path is# parent_path
    call add(a:tree.children, item)
    let b:tree_node_cache[parent_path] = a:tree.children
  endif
endfunction

基本的な処理の流れは次です。

  1. tree(親要素)と、file(GitHubから取得した1ファイルの情報)を受け取る
  2. filepathから親パスを取得
  3. treeに追加するデータitemを作成
  4. fileがディレクトリ(a:file.type is# 'tree'の部分)の場合はtreechildrenを追加
  5. すでにtreechildrenがある場合は、子要素の数だけ再帰処理
  6. treepathfileの親パスと一致した場合、tree.childrenfileを追加
  7. これをAPIレスポンスのtreeの配列分繰り返す

ただ、このロジックではファイルの数とパスの深さと比例して再帰の回数がえげつない回数になるので、ファイル数が3000個くらいあるプロジェクトだとかなりと処理に時間がかかります。

そこですでに作成したノードをキャッシュして子要素の親パスがキャッシュにあったら、
キャッシュしたデータに追加することでパフォーマンスを改善しました。

それが次の部分のコードになります

" キャッシュがあればキャッシュに要素を追加
if has_key(b:tree_node_cache, parent_path)
  call add(b:tree_node_cache[parent_path], item)
  return
endif

...

if tree.path is# parent_path
  call add(a:tree.children, item)
  " 作成済みの要素をキャッシュ
  let b:tree_node_cache[parent_path] = a:tree.children
endif

最後に

キャッシュ戦略でいくぶんパフォーマンスが改善されたとはいえ、
golang/goといった巨大なプロジェクトのファイルツリーを開くのは時間がかかってしまいます。
よりよいロジックがないか、年末あたりに模索してみたいと思います。

gh.vim自体はファイルツリー以外にも、プロジェクトやGitHub Actionsをツリーでみたりできますので、
興味ある方は一度触ってみてください。Vim/Neovimともに動きます。

Discussion