From 6857f9e9b00fa60df04a9a2258018f3a6ead4a8a Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Wed, 21 Oct 2026 16:26:16 +0800 Subject: [PATCH] fix: refresh images when source files change --- lua/image/image.lua | 28 +++++--- lua/types.lua | 2 +- tests/core/image_refresh_spec.lua | 110 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 tests/core/image_refresh_spec.lua diff --git a/lua/image/image.lua b/lua/image/image.lua index 31136d8..89a1446 100644 --- a/lua/image/image.lua +++ b/lua/image/image.lua @@ -1,5 +1,6 @@ local log = require("image/utils/logger").within("image") local renderer = require("image/renderer") +local transform_cache = require("image/utils/transform_cache") local utils = require("image/utils") -- { ["buf:row:col"]: { id, height } } @@ -12,6 +13,12 @@ local Image = { } Image.__index = Image +local get_source_signature = function(path) + local source = transform_cache.source_identity(path) + if not source then return nil end + return table.concat({ source.path, source.mtime, tostring(source.size) }, "\0") +end + ---@param template Image ---@param global_state State ---@return Image @@ -54,11 +61,12 @@ function Image:render(geometry) -- be left in place if vim.fn.getcmdwintype() ~= "" then return end - -- track last_modified and wipe cache - local current_last_modified = vim.fn.getftime(self.original_path) - log.debug(("timestamp: %s, last_modified: %s"):format(current_last_modified, self.last_modified)) - if self.last_modified ~= current_last_modified then - self.last_modified = current_last_modified + -- track source changes and wipe cache + local current_source_signature = get_source_signature(self.original_path) + local source_changed = self.source_signature ~= current_source_signature + log.debug("source changed", { changed = source_changed }) + if source_changed then + self.source_signature = current_source_signature self.resize_hash = nil self.crop_hash = nil self.transform_key = nil @@ -277,9 +285,11 @@ local from_file = function(path, options, state) return nil end - -- bypass processing if already processed + local current_source_signature = get_source_signature(absolute_original_path) + + -- bypass processing if an unchanged source was already processed for _, instance in pairs(state.images) do - if instance.original_path == absolute_original_path then + if instance.original_path == absolute_original_path and instance.source_signature == current_source_signature then local clone = createImage({ id = opts.id or utils.random.id(), path = instance.path, @@ -287,6 +297,7 @@ local from_file = function(path, options, state) cropped_path = instance.path, original_path = instance.original_path, source_format = instance.source_format, + source_signature = current_source_signature, image_width = instance.image_width, image_height = instance.image_height, max_width_window_percentage = instance.max_width_window_percentage, @@ -313,7 +324,6 @@ local from_file = function(path, options, state) crop_hash = nil, resize_hash = nil, namespace = opts.namespace or nil, - last_modified = vim.fn.getftime(absolute_original_path), }, state) log.debug(("cloned image %s from %s"):format(clone.id, instance.id)) return clone @@ -335,6 +345,7 @@ local from_file = function(path, options, state) cropped_path = source_path, original_path = absolute_original_path, source_format = source_format, + source_signature = current_source_signature, image_width = dimensions.width, image_height = dimensions.height, max_width_window_percentage = opts.max_width_window_percentage, @@ -361,7 +372,6 @@ local from_file = function(path, options, state) crop_hash = nil, resize_hash = nil, namespace = opts.namespace or nil, - last_modified = vim.fn.getftime(absolute_original_path), }, state) return instance diff --git a/lua/types.lua b/lua/types.lua index e0a7c54..ec60715 100644 --- a/lua/types.lua +++ b/lua/types.lua @@ -146,7 +146,7 @@ ---@field hue fun(self: Image, hue: number) ---@field namespace? string ---@field extmark? { id: number, row: number, col: number } ----@field last_modified? number +---@field source_signature? string ---@field has_extmark_moved fun (self:Image): (boolean, number?, number?) ---@field ignore_global_max_size? boolean ---@field render_offset_top? number diff --git a/tests/core/image_refresh_spec.lua b/tests/core/image_refresh_spec.lua new file mode 100644 index 0000000..ec5c40d --- /dev/null +++ b/tests/core/image_refresh_spec.lua @@ -0,0 +1,110 @@ +local unload_image_modules = function() + for module_name in pairs(package.loaded) do + if module_name == "image" or module_name:match("^image/") then package.loaded[module_name] = nil end + end +end + +describe("image source refresh", function() + local original_uv + local source_path + local clear_calls + local cache_clear_calls + local dimension_calls + local render_calls + + before_each(function() + unload_image_modules() + original_uv = vim.uv + if not vim.uv then vim.uv = vim.loop end + + clear_calls = 0 + cache_clear_calls = 0 + dimension_calls = 0 + render_calls = 0 + package.loaded["image/renderer"] = { + render = function(image) + render_calls = render_calls + 1 + image.global_state.images[image.id] = image + image.is_rendered = true + return true + end, + clear_cache_for_path = function() + cache_clear_calls = cache_clear_calls + 1 + end, + } + + source_path = vim.fn.tempname() + local file = assert(io.open(source_path, "wb")) + file:write("\x89PNG\r\n\x1A\nfirst") + file:close() + end) + + after_each(function() + vim.uv = original_uv + if source_path then vim.fn.delete(source_path) end + unload_image_modules() + end) + + it("refreshes a changed source even when the second-resolution mtime is unchanged", function() + local image_module = require("image/image") + local state + state = { + enabled = true, + rendering_paused = false, + images = {}, + extmarks_namespace = vim.api.nvim_create_namespace("image-refresh-test"), + processor = { + get_format = function() + return "png" + end, + get_dimensions = function() + dimension_calls = dimension_calls + 1 + return { width = 2, height = 2 } + end, + }, + backend = { + clear = function(id, shallow) + clear_calls = clear_calls + 1 + local image = state.images[id] + if image then + image.is_rendered = false + if not shallow then state.images[id] = nil end + end + end, + }, + } + + local image = image_module.from_file(source_path, nil, state) + image:render() + assert.are.same(1, dimension_calls) + assert.are.same(1, render_calls) + assert.are.same(0, clear_calls) + + local original_mtime_seconds = vim.fn.getftime(source_path) + local original_stat = assert(vim.uv.fs_stat(source_path)) + local file = assert(io.open(source_path, "wb")) + file:write("\x89PNG\r\n\x1A\nother") + file:close() + local updated_stat = assert(vim.uv.fs_stat(source_path)) + assert.are.same(original_stat.size, updated_stat.size) + local changed_mtime_nsec = original_stat.mtime.nsec == 250000000 and 750000000 or 250000000 + assert.is_true( + vim.uv.fs_utime(source_path, original_stat.atime.sec, original_stat.mtime.sec + changed_mtime_nsec / 1000000000) + ) + local changed_stat = assert(vim.uv.fs_stat(source_path)) + assert.are.same(original_stat.mtime.sec, changed_stat.mtime.sec) + assert.are.same(false, original_stat.mtime.nsec == changed_stat.mtime.nsec) + assert.are.same(original_mtime_seconds, vim.fn.getftime(source_path)) + + local reloaded_image = image_module.from_file(source_path, nil, state) + assert.are.same(false, image == reloaded_image) + assert.are.same(false, image.source_signature == reloaded_image.source_signature) + assert.are.same(2, dimension_calls) + + image:render() + assert.are.same(3, dimension_calls) + assert.are.same(2, render_calls) + assert.are.same(1, clear_calls) + assert.are.same(1, cache_clear_calls) + end) +end)