## 経緯
[[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>