Files
ideadrop.nvim/lua/ideaDrop/ui/graph/cache.lua
Carlos Gutierrez 0d1aa591e5 perf: add caching system for fast graph loading
Major performance improvements for graph visualization:

Cache System:
- Add cache.lua module that stores parsed links/tags per file
- Only re-parse files that have been modified (mtime check)
- Cache stored in .ideadrop-graph-cache.json in idea_dir
- Fast file scanning using vim.fs.find when available

Layout Optimizations:
- Reduce max iterations from 300 to 100
- Faster convergence with adjusted parameters
- Barnes-Hut approximation for large graphs (100+ nodes)
- Cache math functions locally for speed
- Skip distant node pairs in repulsion calculation
- Reuse visible_nodes array across iterations

New Commands:
- :IdeaGraph rebuild - Force full cache rebuild
- :IdeaGraphClearCache - Clear cache file

This makes opening the graph nearly instant for previously
scanned vaults, similar to Obsidian's behavior.
2026-01-10 23:18:49 -05:00

274 lines
6.4 KiB
Lua

-- ideaDrop/ui/graph/cache.lua
-- Graph cache system for fast loading - compatible with Obsidian vaults
local config = require("ideaDrop.core.config")
---@class GraphCache
---@field load fun(): table|nil
---@field save fun(data: table): nil
---@field get_file_mtime fun(path: string): number
---@field is_stale fun(cached_entry: table, file_path: string): boolean
local M = {}
-- Cache filename
local CACHE_FILE = ".ideadrop-graph-cache.json"
local OBSIDIAN_CACHE = ".obsidian/graph.json"
---Gets the cache file path
---@return string
local function get_cache_path()
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
idea_dir = vim.fn.expand(idea_dir or "")
return idea_dir .. "/" .. CACHE_FILE
end
---Gets the Obsidian cache file path
---@return string
local function get_obsidian_cache_path()
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
idea_dir = vim.fn.expand(idea_dir or "")
return idea_dir .. "/" .. OBSIDIAN_CACHE
end
---Gets file modification time
---@param path string File path
---@return number Modification time (0 if file doesn't exist)
function M.get_file_mtime(path)
local stat = vim.loop.fs_stat(path)
if stat then
return stat.mtime.sec
end
return 0
end
---Checks if a cached entry is stale (file has been modified)
---@param cached_entry table Cached file data
---@param file_path string Current file path
---@return boolean True if cache is stale
function M.is_stale(cached_entry, file_path)
if not cached_entry or not cached_entry.mtime then
return true
end
local current_mtime = M.get_file_mtime(file_path)
return current_mtime ~= cached_entry.mtime
end
---Loads the cache from disk
---@return table|nil Cache data or nil if not found/invalid
function M.load()
local cache_path = get_cache_path()
-- Check if cache file exists
if vim.fn.filereadable(cache_path) == 0 then
return nil
end
-- Read and parse cache
local ok, content = pcall(vim.fn.readfile, cache_path)
if not ok or #content == 0 then
return nil
end
local json_str = table.concat(content, "\n")
local ok2, data = pcall(vim.fn.json_decode, json_str)
if not ok2 or type(data) ~= "table" then
return nil
end
return data
end
---Saves the cache to disk
---@param data table Cache data to save
function M.save(data)
local cache_path = get_cache_path()
local ok, json_str = pcall(vim.fn.json_encode, data)
if not ok then
return
end
-- Write cache file
local file = io.open(cache_path, "w")
if file then
file:write(json_str)
file:close()
end
end
---Tries to load Obsidian's graph cache
---@return table|nil Obsidian graph data or nil
function M.load_obsidian_cache()
local obsidian_path = get_obsidian_cache_path()
if vim.fn.filereadable(obsidian_path) == 0 then
return nil
end
local ok, content = pcall(vim.fn.readfile, obsidian_path)
if not ok or #content == 0 then
return nil
end
local json_str = table.concat(content, "\n")
local ok2, data = pcall(vim.fn.json_decode, json_str)
if not ok2 or type(data) ~= "table" then
return nil
end
return data
end
---Extracts links from content (fast version)
---@param content string File content
---@return string[] Array of link targets
function M.extract_links_fast(content)
local links = {}
local seen = {}
-- Fast pattern matching for [[link]] and [[link|alias]]
for link in content:gmatch("%[%[([^%]|]+)") do
link = link:gsub("^%s+", ""):gsub("%s+$", "") -- trim
if link ~= "" and not seen[link] then
links[#links + 1] = link
seen[link] = true
end
end
return links
end
---Extracts tags from content (fast version)
---@param content string File content
---@return string[] Array of tags
function M.extract_tags_fast(content)
local tags = {}
local seen = {}
for tag in content:gmatch("#([%w%-_]+)") do
if not seen[tag] and #tag > 1 then
tags[#tags + 1] = tag
seen[tag] = true
end
end
return tags
end
---Builds cache data for a single file
---@param file_path string File path
---@return table|nil File cache entry
function M.build_file_cache(file_path)
local mtime = M.get_file_mtime(file_path)
if mtime == 0 then
return nil
end
-- Read file content
local ok, lines = pcall(vim.fn.readfile, file_path)
if not ok then
return nil
end
local content = table.concat(lines, "\n")
return {
mtime = mtime,
links = M.extract_links_fast(content),
tags = M.extract_tags_fast(content),
}
end
---Gets all markdown files using fast directory scan
---@param idea_dir string Directory to scan
---@return string[] Array of file paths
function M.scan_files_fast(idea_dir)
local files = {}
-- Use vim.fs.find for faster scanning (Neovim 0.8+)
if vim.fs and vim.fs.find then
local found = vim.fs.find(function(name)
return name:match("%.md$")
end, {
path = idea_dir,
type = "file",
limit = math.huge,
})
return found
end
-- Fallback to glob
files = vim.fn.glob(idea_dir .. "/**/*.md", false, true)
if #files == 0 then
files = vim.fn.glob(idea_dir .. "/*.md", false, true)
end
return files
end
---Builds or updates the complete cache
---@param force boolean|nil Force full rebuild
---@return table Cache data with files map
function M.build_cache(force)
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
idea_dir = vim.fn.expand(idea_dir or ""):gsub("/$", "")
-- Load existing cache
local cache = nil
if not force then
cache = M.load()
end
cache = cache or { files = {}, version = 1 }
-- Get all current files
local current_files = M.scan_files_fast(idea_dir)
local current_files_set = {}
for _, f in ipairs(current_files) do
current_files_set[f] = true
end
-- Remove deleted files from cache
local new_files_cache = {}
for path, entry in pairs(cache.files or {}) do
if current_files_set[path] then
new_files_cache[path] = entry
end
end
cache.files = new_files_cache
-- Update cache for new/modified files
local updated = 0
local skipped = 0
for _, file_path in ipairs(current_files) do
local cached = cache.files[file_path]
if M.is_stale(cached, file_path) then
local entry = M.build_file_cache(file_path)
if entry then
cache.files[file_path] = entry
updated = updated + 1
end
else
skipped = skipped + 1
end
end
-- Save updated cache
if updated > 0 then
M.save(cache)
end
return cache, updated, skipped
end
---Clears the cache
function M.clear()
local cache_path = get_cache_path()
if vim.fn.filereadable(cache_path) == 1 then
vim.fn.delete(cache_path)
end
end
return M