🐼

VimによるToDo管理を支える技術

2024/02/14に公開

背景

ToDo管理、大変ですよね。
世間には色んなツールがありますし、様々な方法論も溢れています。

しかしそのどれも、非常に大きな問題を抱えています。

Vimじゃない

Vimじゃないんです。そう。われわれはVimに魂を奪われた悲しき獣。
Vim以外の媒体で文字を書くと、全身に蕁麻疹が出て、手足は震え視野が狭窄し、やがて死に至ります。

解決は小さく

もう1つの問題として、世にある様々なToDo管理は

  • 完成度の高い、巨大なツール
  • 本が何冊も出るような、複雑な方法論

を必要としています。
そのため、学習コストが高く、始めるのも慣れるにも時間がかかります。

怠惰だからToDo管理をしたいのに、怠惰な人間には辛い初期コストを要するという理不尽。
私はそんな理不尽に抗えるほどまともな人間ではないので、小さく始めて、問題に直面するたび、少しずつ改善していくようにしたいものです。

スコープも小さく

これもToDo管理をしようとした私がよくくじける原因の1つですが、
何でもかんでも管理しようとして、本当にやるべきことを見失ってしまいがちです。

ToDo管理として常時にらむ対象は優先度に従って並べられる程度には少なく、自明に直近でやるべきことだけにしています。
それ以外は適当なリマインドリスト(Notion, GitHub Issue)などに流して、暇なときに眺める程度。たまに思い付きで取り組むこともありますが、基本はリスト化した段階で満足して、放置しています。

解決策たち

本稿では、私がそうして向き合うために必要とした小さな解決策たちを、一個一個列挙して紹介しようと思います。

todo.mdファイルを開くキーマップ

ToDoはすべて1ファイルで管理しています
プライベートのタスクも、仕事のタスクも、それぞれそのための端末に保存してあるため、端末を切り替えればToDoファイルの中身はそれぞれ。
ただ、ToDoファイルへのアクセスは手軽であればあるほど良いのは間違いありません。
開くのが面倒くさいファイルは、やがて忘れ去られて陳腐化します。

そのため、あえてToDoファイルを開くためだけのキーマップを設定しました。

vim.keymap.set("n", "<leader><leader>t", [[<cmd>new ~/.local/state/to]] .. [[do.md<cr>]], { remap = false, desc = "作業メモを編集する" })
nnoremap <leader><leader>t <cmd>new ~/.local/state/todo.md<cr>

\\tと入力するだけで、ファイル~/.local/state/todo.mdをウィンドウ分割で開きます。

シンボリックリンク

(Vimは関係ないですが)

ToDoファイル自体、場合によっては外部のストレージと同期しておきたい場合もあるでしょう。
私はプライベートの予定は自分のGoogle Driveと同期しています。

しかし、外部ストレージとの同期はサービスによって置き場所が変わったりするので、サービスごとの設定が必要になったりと面倒が増えます。

そんな面倒を避けるため、スタティックリンクを張ってどの端末でも同じ~/.local/state/todo.mdでアクセスできるようにしています。

例:

$ ln -s $HOME/gdrive/my/todo.md $HOME/.local/state/todo.md

リンクをブラウザで開くキーマップ

ToDoファイルには、取り組むべき課題のお題目だけを書くようにしています。
そのため、詳細は例えばGitHubのIssueなど、外部のタスク管理サービスに任せています。

- hogeする kyoh86/todo#123
- fugaする kyoh86/todo#123

そして、ToDoファイルに張られたリンクを直接Vimから開けるようにしています。

local function open_cursor()
  local target = vim.fn.expand("<cfile>")
  if target == nil then
    print("No target found at cursor.")
    return
  end
  target = string.gsub(target --[[@as string]], [[\.+$]], "")
  local repo, number = string.match(target, "^([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)#(%d+)$")
  if repo and number then
    -- targetがowner/repo#nnnの形ならgh を呼んで指定のリポジトリのIssueを開く
    local cmd = { "gh", "issue", "view", "--repo", repo, "--web", number }
    vim.fn.jobstart(cmd, {
      on_exit = function(_, code)
        if code ~= 0 then
          vim.print("Failed to open issue.")
          vim.print(repo, number)
        end
      end,
    })
  else
    vim.ui.open(target)
  end
end

vim.keymap.set("n", "<plug>(open-cursor-file)", open_cursor, { silent = true, remap = false, nowait = true })

vim.keymap.set("n", "gx", "<plug>(open-cursor-file)", {})

(Vim script版は誰か書いてくれてもいいんですよ)

外部ファイルのパスや、owner/repo#nnnの形で書かれたIssueリンクがあれば、カーソルを合わせてgxとタイプすることで直接開かれるようになっています。
Vimの<cfile>は、万能ではないですが便利ですね。

チェックボックスを操作するキーマップ

各タスクをMarkdownのチェックリストで書いておくと、1日/1週など特定期間での振り返りがしやすくなります。
とはいえMarkdownのチェックリストは、Vimだと扱いにくいので、カーソル行のチェックリストをチェック済/未チェックで切り替えるキーマップがあると便利です。

local function get_range(args)
  -- get target range from user command args
  local from = args.line1
  local to = args.line2
  local another = vim.fn.line("v")
  if another == nil then
    return nil, nil
  end
  if from == to and from ~= another then
    if another < from then
      from = another
    else
      to = another
    end
  end
  return from, to
end

--- markdownのCheckbox化・CheckboxのオンオフのToggle
local LIST_PATTERN = [[^\s*\([\*+-]\|[0-9]\+\.\)\s\+]]
local function toggle_checkbox(args)
  local from, to = get_range(args)
  if from == nil or to == nil then
    vim.notify("failed to get range to toggle checkbox", vim.log.levels.ERROR)
    return
  end
  local curpos = vim.fn.getcursorcharpos()
  local lines = vim.fn.getline(from, to)

  for lnum = from, to, 1 do
    local line = lines[lnum - from + 1] --[[@as string]]

    if not vim.regex(LIST_PATTERN):match_str(line) then
      -- not list -> add list marker and blank box
      vim.fn.setline(lnum, vim.fn.substitute(line, [[\v\S|$]], [[- [ ] \0]], ""))
      if lnum == curpos[1] then
        vim.fn.setcursorcharpos({ curpos[1], curpos[2] + 6 })
      end
    elseif vim.regex(LIST_PATTERN .. [[\[ \]\s\+]]):match_str(line) then
      -- blank box -> check
      vim.fn.setline(lnum, vim.fn.substitute(line, "\\[ \\]", "[x]", ""))
    elseif vim.regex(LIST_PATTERN .. [[\[x\]\s\+]]):match_str(line) then
      -- checked box -> uncheck
      vim.fn.setline(lnum, vim.fn.substitute(line, "\\[x\\]", "[ ]", ""))
    else
      -- list but no box -> add box after list marker
      vim.fn.setline(lnum, vim.fn.substitute(line, [[\S\+]], "\\0 [ ]", ""))
      if lnum == curpos[1] then
        vim.fn.setcursorcharpos({ curpos[1], curpos[2] + 4 })
      end
    end
  end
end

vim.api.nvim_buf_create_user_command(0, "MarkdownToggleCheckbox", toggle_checkbox, { range = true, force = true })
vim.keymap.set({ "n", "i", "x" }, "<leader>mcc", "<cmd>MarkdownToggleCheckbox<cr>", { buffer = true })

こちらの機能は別記事で@kawarimidollさんが書いている記事にならっています。

https://zenn.dev/vim_jp/articles/4564e6e5c2866d

Issueへのリンクを気軽に張る

Issueに気軽に飛べるようにするなら、当然そのリンクを気軽に張れるようにするのも大事です。
Fuzzy FinderなどでIssueを列挙し、リンクをバッファに張る機能をつけておくと便利です。

https://www.youtube.com/watch?v=fKFzJaTDhrA

この例ではddu.vimを使っていますが、他のFuzzy Finderでも同様のことはできるでしょう。便利。

Discussion