## 経緯 [[obsidian.nvim]]で[[snacks.picker]]を表示するときは、ファイルを開きたいときの他に、[[wikiリンク]]を挿入したいケースが多々ある。しかも、その場合は **さっき開いたファイル** や **さっき作成したファイル** を挿入したいことが多い。 一方、デフォルトの表示順は以下のようになっている。 - キーワード未入力時 - ファイルパスの昇順 - キーワード入力時 - ファイルパスの文字数昇順 (恐らく) どちらも **最近開いたファイル順** にしたい。 ## 既存の挙動調査 [[obsidian.nvim]]の挙動を調べる。 ### リポジトリ forkしているので実際のリポジトリは以下。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://github.githubassets.com/favicons/favicon.svg" /> <span class="link-card-v2-site-name">GitHub</span> </div> <div class="link-card-v2-title"> GitHub - tadashi-aikawa/obsidian.nvim: Obsidian 🤝 Neovim </div> <div class="link-card-v2-content"> Obsidian 🤝 Neovim. Contribute to tadashi-aikawa/obsidian.nvim development by creating an account on GitHub. </div> <img class="link-card-v2-image" src="https://opengraph.githubassets.com/ca0225478a8f87b52e56d97bf3f8ac759d70eaee9cbf117fa2b9e7de74fc3ad2/tadashi-aikawa/obsidian.nvim" /> <a href="https://github.com/tadashi-aikawa/obsidian.nvim"></a> </div> ### ObsidianQuickSwitchコマンドのエンドポイント pickerを作って `find_notes` を呼び出している。 `commands/quick_switch.lua` ```lua ---@param client obsidian.Client return function(client, data) if not data.args or string.len(data.args) == 0 then -- ★picker作成 local picker = client:picker() if not picker then log.err "No picker configured" return end -- ★ここ picker:find_notes() else client:resolve_note_async_with_picker_fallback(data.args, function(note) client:open_note(note) end) end end ``` ### pickerの生成 picker作成先を見る。 `client.lua` ```lua --- Get the Picker. --- ---@param picker_name obsidian.config.Picker|? --- ---@return obsidian.Picker|? Client.picker = function(self, picker_name) return require("obsidian.pickers").get(self, picker_name) end ``` `pickers/init.lua` ```lua -- ★オプションで local PickerName = require("obsidian.config").Picker local M = {} --- Get the default Picker. --- ---@param client obsidian.Client ---@param picker_name obsidian.config.Picker|? --- ---@return obsidian.Picker|? M.get = function(client, picker_name) picker_name = picker_name and picker_name or client.opts.picker.name if picker_name then picker_name = string.lower(picker_name) else for _, name in ipairs { PickerName.telescope, PickerName.fzf_lua, PickerName.mini, PickerName.snacks } do local ok, res = pcall(M.get, client, name) if ok then return res end end return nil end if picker_name == string.lower(PickerName.telescope) then return require("obsidian.pickers._telescope").new(client) elseif picker_name == string.lower(PickerName.mini) then return require("obsidian.pickers._mini").new(client) elseif picker_name == string.lower(PickerName.fzf_lua) then return require("obsidian.pickers._fzf").new(client) elseif picker_name == string.lower(PickerName.snacks) then return require("obsidian.pickers._snacks").new(client) elseif picker_name then error("not implemented for " .. picker_name) end end return M ``` オプションの `picker.name` に従って、異なるclientを返却するfactory method。自分のオプションは以下のようになっているので、`obsidian.pickers._snacks` が使われる。 ```lua picker = { name = "snacks.pick", }, ``` `pickers/_snacks.lua` ```lua ---@class obsidian.pickers.SnacksPicker : obsidian.Picker local SnacksPicker = abc.new_class({ ---@diagnostic disable-next-line: unused-local __tostring = function(self) return "SnacksPicker()" end, }, Picker) ``` 詳細は割愛するが、[[obsidian.nvim]]のベース実装を元に拡張したクラスが作成できてそうな感じ。 ### find_note 一度 [[#エンドポイント]] に戻って `find_note` の先を見てみる。 ```lua picker:find_notes() ``` `pickers/picker.lua` ```lua --- Find notes by filename. --- ---@param opts { prompt_title: string|?, callback: fun(path: string)|?, no_default_mappings: boolean|? }|? Options. --- --- Options: --- `prompt_title`: Title for the prompt window. --- `callback`: Callback to run with the selected note path. --- `no_default_mappings`: Don't apply picker's default mappings. Picker.find_notes = function(self, opts) self.calling_bufnr = vim.api.nvim_get_current_buf() opts = opts or {} local query_mappings local selection_mappings if not opts.no_default_mappings then query_mappings = self:_note_query_mappings() selection_mappings = self:_note_selection_mappings() end -- ★ self:find_filesの実装にカギがある? return self:find_files { prompt_title = opts.prompt_title or "Notes", dir = self.client.dir, callback = opts.callback, no_default_mappings = opts.no_default_mappings, query_mappings = query_mappings, selection_mappings = selection_mappings, } end ``` ここではキーマッピングやコールバックの挙動を設定しているが、特筆すべきなのは **候補の一覧を指定するクチがない** ということ。今回の目的は、**候補の一覧を並び替えること** にあるので糸口がつかめない。 しかし `self:find_files` を実行している以上、`self:find_files` にはその情報があると思って間違いない。そこを深堀する。 ### find_files pickerは抽象化されているので、実装はされていない。 `pickers/picker.lua` ```lua --- Find files in a directory. --- ---@param opts obsidian.PickerFindOpts|? Options. --- --- Options: --- `prompt_title`: Title for the prompt window. --- `dir`: Directory to search in. --- `callback`: Callback to run with the selected entry. --- `no_default_mappings`: Don't apply picker's default mappings. --- `query_mappings`: Mappings that run with the query prompt. --- `selection_mappings`: Mappings that run with the current selection. --- ---@diagnostic disable-next-line: unused-local Picker.find_files = function(self, opts) error "not implemented" end ``` ここで、改めてパラメータについての説明があるが、想定通りなので触れないでおく。 `find_files` の実装はPickerの実装に委ねられることになるため、[[#pickerの生成]]で触れた `pickers/_snacks.lua` を見る。 `pickers/_snacks.lua` ```lua ---@param opts obsidian.PickerFindOpts|? Options. SnacksPicker.find_files = function(self, opts) opts = opts or {} ---@type obsidian.Path local dir = opts.dir.filename and Path:new(opts.dir.filename) or self.client.dir local selection_map = selection_mappings(opts.selection_mappings) or {} local query_map = query_mappings(opts.query_mappings) or {} local map = vim.tbl_deep_extend("force", {}, selection_map, query_map) local pick_opts = vim.tbl_extend("force", map or {}, { source = "files", title = opts.prompt_title, cwd = tostring(dir), confirm = function(picker, item, action) picker:close() if item then if opts.callback then opts.callback(item._path) else snacks_picker.actions.jump(picker, item, action) end end end, }) -- ★この先に候補一覧のカギがある? snacks_picker.pick(pick_opts) end ``` `opts` で指定されたパラメータを抽出し、[[snacks.picker]]のIFへと変換している。`selection_mappings` と `query_mappings` はスコープから外れるので気にしなくてよい。 さて、ここでも **候補の一覧を指定するクチがなさそうに見える**... が実はそうでもない。 ```lua source = "files", ``` これは[[snacks.picker]]でファイル検索用に用意された `files` のsourceを使っているということだ。ここにデフォルト設定が隠されている。 ### snacks.pickerのsource `:h snacks-picker-sources` で [[snacks.picker]]が対応するsourceの一覧を確認できる。`files` の定義は以下のようになっている。 ```lua FILES *snacks-picker-sources-files* >vim :lua Snacks.picker.files(opts?) < >lua ---@class snacks.picker.files.Config: snacks.picker.proc.Config ---@field cmd? "fd"| "rg"| "find" command to use. Leave empty to auto-detect ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field ft? string|string[] file extension(s) ---@field rtp? boolean search in runtimepath { finder = "files", format = "file", show_empty = true, hidden = false, ignored = false, follow = false, supports_live = true, } ``` 一方で、今回の目的にあいそうなsourceも存在する。 ```lua RECENT *snacks-picker-sources-recent* >vim :lua Snacks.picker.recent(opts?) < Find recent files >lua ---@class snacks.picker.recent.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config { finder = "recent_files", format = "file", filter = { paths = { [vim.fn.stdpath("data")] = false, [vim.fn.stdpath("cache")] = false, [vim.fn.stdpath("state")] = false, }, }, } ``` ### finderの深堀 `finder = "recent_files"` とあることから、候補の一覧表示には `finder` という概念が強く関わってそうである。ここをもう少し調べてみる。 `snacks/picker/core/picker.lua` ```lua self.finder = Finder.new(Snacks.picker.config.finder(self.opts.finder) or function() return self.opts.items or {} end) ``` 設定で指定した `finder` は `self.opts.finder` として使われていそう。`Snacks.picker.config.finder` は文字列か関数を受け付け、文字列だと[[#finderの一覧]]に該当する関数が指定される。関数を独自に指定することで、自分だけのfinderを作ることもできそう。 `snacks/picker/config.init.lua` ```lua --- Get the finder ---@param finder string|snacks.picker.finder|snacks.picker.finder.multi ---@return snacks.picker.finder function M.finder(finder) local nop = function() Snacks.notify.error("Finder not found:\n```lua\n" .. vim.inspect(finder) .. "\n```", { title = "Snacks Picker" }) end if not finder or type(finder) == "function" then return finder end if type(finder) == "table" then ---@cast finder snacks.picker.finder.multi ---@type snacks.picker.finder[] local finders = vim.tbl_map(function(f) return M.finder(f) end, finder) return require("snacks.picker.core.finder").multi(finders) end ---@cast finder string return M.field(finder) or nop end ``` `finder` の実装は `snacks/picker/source/` 配下にありそう。 #### finderの一覧 ``` "buffers", "diagnostics", "explorer", "files", "files_zoxide", "git_branches", "git_diff", "git_files", "git_grep", "git_log", "git_stash", "git_status", "grep", "help", "icons", "lazy_spec", "lines", "lsp.config#find", "lsp_declarations", "lsp_definitions", "lsp_implementations", "lsp_references", "lsp_symbols", "lsp_type_definitions", "meta_actions", "meta_format", "meta_layouts", "meta_pickers", "meta_preview", "qf", "recent_files", "recent_projects", "snacks_notifier", "system_cliphist", "system_man", "treesitter_symbols", "vim_autocmds", "vim_colorschemes", "vim_commands", "vim_highlights", "vim_history", "vim_jumps", "vim_keymaps", "vim_marks", "vim_registers", "vim_spelling", "vim_undo", ``` ## sourceをrecentに変更する sourceを `recent` に変更してみた。 `pickers/_snacks.lua` ```diff local pick_opts = vim.tbl_extend("force", map or {}, { - source = "files", + source = "recent", title = opts.prompt_title, cwd = tostring(dir), confirm = function(picker, item, action) picker:close() if item then if opts.callback then opts.callback(item._path) else snacks_picker.actions.jump(picker, item, action) end end end, }) ``` これで [[snacks.picker]] の `Snacks.picker.recent()` と同じ候補が同じ順番で表示されるようになった。しかし、いくつか課題が残されている。 ## トラブル ### Vaultに関係ない結果が表示される > [[snacks.picker]] の `Snacks.picker.recent()` と同じ候補が同じ順番で表示される これではVaultに関係ないリソースまで表示されてしまい、ノイズになってしまう。Vault内部の結果のみを表示するようにしたい。`filter = { cwd = true }` を指定することで、[[カレントディレクトリ (Neovim)|カレントディレクトリ]]以下に制限できる。 `pickers/_snacks.lua` ```diff local pick_opts = vim.tbl_extend("force", map or {}, { source = "recent", + filter = { cwd = true }, title = opts.prompt_title, cwd = tostring(dir), confirm = function(picker, item, action) picker:close() if item then if opts.callback then opts.callback(item._path) else snacks_picker.actions.jump(picker, item, action) end end end, }) ``` ### 一度参照したものしか表示されない 一度も参照していないファイルが結果に表示されていなそうである。`recent` はもともと履歴ファイルの一覧を表示する機能であるため、これは妥当。 一方で、[[obsidian.nvim]]としては一度も開いていないファイルも検索対象に加えたい。そのためには、sourceを`files`に戻し、戦略を変更する必要がある。 一度、[[#sourceをrecentに変更する]] 以前の状態に戻す。 `pickers/_snacks.lua` ```diff local pick_opts = vim.tbl_extend("force", map or {}, { source = "files", title = opts.prompt_title, cwd = tostring(dir), confirm = function(picker, item, action) picker:close() if item then if opts.callback then opts.callback(item._path) else snacks_picker.actions.jump(picker, item, action) end end end, }) ``` 変更したいのはソート順なので、そのようなパラメータを指定できないか調べてみる。`h snacks-picker-config` で設定を確認する。 気になったのは `matcher` と `sort` の2つ。 ```lua matcher = { fuzzy = true, -- use fuzzy matching smartcase = true, -- use smartcase ignorecase = true, -- use ignorecase sort_empty = false, -- sort results when the search string is empty filename_bonus = true, -- give bonus for matching file names (last part of the path) file_pos = true, -- support patterns like `file:line:col` and `file:line` -- the bonusses below, possibly require string concatenation and path normalization, -- so this can have a performance impact for large lists and increase memory usage cwd_bonus = false, -- give bonus for matching files in the cwd frecency = false, -- frecency bonus history_bonus = false, -- give more weight to chronological order }, sort = { -- default sort is by score, text length and index fields = { "score:desc", "#text", "idx" }, }, ``` `sort` に最近アクセスした時刻のようなものがあれば良いが、それはなさそう。`matcher` の方は `sort_empty = true` を設定する可能性はあるので覚えておく。`frecency` と `history_bonus` は単なる加点であって、**必ずアクセスした順に並ぶ** というわけではないと思う。 アプローチを変えて、`finder` を調べる。`recent_files` の実装は恐らくここ。 `snacks/picker/source/recent.lua` ```lua --- Get the most recent files, optionally filtered by the --- current working directory or a custom directory. ---@param opts snacks.picker.recent.Config ---@type snacks.picker.finder function M.files(opts, ctx) local current_file = svim.fs.normalize(vim.api.nvim_buf_get_name(0), { _fast = true }) ---@type number[] local bufs = vim.tbl_filter(function(b) return vim.api.nvim_buf_get_name(b) ~= "" and vim.bo[b].buftype == "" and vim.bo[b].buflisted end, vim.api.nvim_list_bufs()) table.sort(bufs, function(a, b) return vim.fn.getbufinfo(a)[1].lastused > vim.fn.getbufinfo(b)[1].lastused end) local extra = vim.tbl_map(function(b) return vim.api.nvim_buf_get_name(b) end, bufs) ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) for file in oldfiles(ctx.filter, extra) do if file ~= current_file then cb({ file = file, text = file, recent = true }) end end end end ``` 想像通り、**[[snacks.picker]]のrecentはターゲットディレクトリのファイルをすべて表示するようにできていない** ので、`recent` を使う場合はその前提で使い分ける必要がありそう。これは[[obsidian.nvim]]に限った話ではない。 なので、以下の設定に一度戻す。 ```lua local pick_opts = vim.tbl_extend("force", map or {}, { source = "recent", filter = { cwd = true }, title = opts.prompt_title, cwd = tostring(dir), confirm = function(picker, item, action) picker:close() if item then if opts.callback then opts.callback(item._path) else snacks_picker.actions.jump(picker, item, action) end end end, }) ``` ## ObsidianQuickSwitchRecentコマンドの追加 [[#一度参照したものしか表示されない]] 問題を解決する。 [[#ObsidianQuickSwitchコマンドのエンドポイント]] 以降の流れに沿って、新しいコマンドとして `ObsidianQuickSwitchRecent` コマンドを作成する。 ### コマンドのエントリポイントファイルを作成 `commands/quick_switch_recent.lua` ```lua local log = require "obsidian.log" ---@param client obsidian.Client return function(client, data) if not data.args or string.len(data.args) == 0 then local picker = client:picker() if not picker then log.err "No picker configured" return end -- ★引数にオプションを2つ追加 picker:find_notes { prompt_title = "Recent opened notes", recent = true } else client:resolve_note_async_with_picker_fallback(data.args, function(note) client:open_note(note) end) end end ``` `prompt_title` は既存のパラメータだが、`recent` は今回追加するもの。この後に説明する。 ### find_noteの変更 `recent` を `opts` パラメータに追加し、`self:find_files` に流し込む。 `pickers/picker.lua` ```lua --- Find notes by filename. --- --- ★recentを追加 ---@param opts { prompt_title: string|?, callback: fun(path: string)|?, no_default_mappings: boolean|?, recent: boolean|? }|? Options. --- --- Options: --- `prompt_title`: Title for the prompt window. --- `callback`: Callback to run with the selected note path. --- `no_default_mappings`: Don't apply picker's default mappings. Picker.find_notes = function(self, opts) self.calling_bufnr = vim.api.nvim_get_current_buf() opts = opts or {} local query_mappings local selection_mappings if not opts.no_default_mappings then query_mappings = self:_note_query_mappings() selection_mappings = self:_note_selection_mappings() end return self:find_files { prompt_title = opts.prompt_title or "Notes", dir = self.client.dir, callback = opts.callback, no_default_mappings = opts.no_default_mappings, query_mappings = query_mappings, selection_mappings = selection_mappings, -- ★追加 recent = opts.recent, } end ``` 同ファイル50行目付近の型定義にも追加。 ```lua ---@class obsidian.PickerFindOpts --- ---@field prompt_title string|? ---@field dir string|obsidian.Path|? ---@field callback fun(path: string)|? ---@field no_default_mappings boolean|? ---@field query_mappings obsidian.PickerMappingTable|? ---@field selection_mappings obsidian.PickerMappingTable|? --- ★追加 ---@field recent boolean|? ``` ### find_filesの変更 [[snacks.picker]]用の[[ファジーファインダー]]処理で、`recent` が `true` の場合のみ、パラメータを変えてあげればOK。 `pickers/_snacks.lua` ```lua ---@param opts obsidian.PickerFindOpts|? Options. SnacksPicker.find_files = function(self, opts) opts = opts or {} ---@type obsidian.Path local dir = opts.dir.filename and Path:new(opts.dir.filename) or self.client.dir local selection_map = selection_mappings(opts.selection_mappings) or {} local query_map = query_mappings(opts.query_mappings) or {} -- ★追加 local source_set = opts.recent and { source = "recent", filter = { cwd = true }, } or { source = "files", cwd = tostring(dir), } -- ★source_setを追加 local map = vim.tbl_deep_extend("force", {}, selection_map, query_map, source_set) local pick_opts = vim.tbl_extend("force", map or {}, { -- ★source_setに移行したパラメータを削除 title = opts.prompt_title, confirm = function(picker, item, action) picker:close() if item then if opts.callback then opts.callback(item._path) else snacks_picker.actions.jump(picker, item, action) end end end, }) snacks_picker.pick(pick_opts) end ``` ### init.luaにコマンド追加 最後にコマンドを開放する。二か所変更。 `commands/init.lua` ```diff ObsidianQuickSwitch = "obsidian.commands.quick_switch", + ObsidianQuickSwitchRecent = "obsidian.commands.quick_switch_recent", ``` `commands/init.lua` ```diff M.register("ObsidianQuickSwitch", { opts = { nargs = "?", desc = "Switch notes" } }) + M.register("ObsidianQuickSwitchRecent", { opts = { nargs = "?", desc = "Switch notes recent" } }) ``` ## obsidian.nvimの設定 [[obsidian.nvim]]の設定に新しいコマンドをキーバインドしてあげる。 ```lua mappings = { ["<C-j>r"] = { mode = { "n", "i" }, action = "<cmd>ObsidianQuickSwitchRecent<CR>", opts = { noremap = false, expr = false, buffer = true }, }, }, ``` ## 該当コミット <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://github.githubassets.com/favicons/favicon.svg" /> <span class="link-card-v2-site-name">GitHub</span> </div> <div class="link-card-v2-title"> feat: Add an ObsidianQuickSwitchRecent command (only support for snac… · tadashi-aikawa/obsidian.nvim@9afeb34 </div> <div class="link-card-v2-content"> …k.picker) </div> <img class="link-card-v2-image" src="https://opengraph.githubassets.com/e8cd5eef6ff17669e6847457886a2d04cb9e756208badd15b6b6213c92547de3/tadashi-aikawa/obsidian.nvim/commit/9afeb34401a40f174f801e5384108852230f0d4c" /> <a href="https://github.com/tadashi-aikawa/obsidian.nvim/commit/9afeb34401a40f174f801e5384108852230f0d4c"></a> </div>