-- The MIT License (MIT) -- -- Copyright © 2026 Scott E. Graves -- -- Permission is hereby granted, free of charge, to any person obtaining a copy of this software -- and associated documentation files (the “Software”), to deal in the Software without restriction, -- including without limitation the rights to use, copy, modify, merge, publish, distribute, -- sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is -- furnished to do so, subject to the following conditions: -- -- The above copyright notice and this permission notice shall be included in all copies or -- substantial portions of the Software. -- -- THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -- BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -- DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -- require("darcula.utils.string") require("darcula.utils.table") local Path = require("plenary.path") local action_state = require("telescope.actions.state") local actions = require("telescope.actions") local conf = require("telescope.config").values local finders = require("telescope.finders") local utils = require("darcula.utils") local global_state = require("telescope.state") local utils_os = require("darcula.utils.os") local utils_path = require("darcula.utils.path") local pickers = require("telescope.pickers") local plenary_filetype = require("plenary.filetype") local previewer_utils = require("telescope.previewers.utils") local previewers = require("telescope.previewers") local scan = require("plenary.scandir") local M = {} local active_saves = {} local changed_lookup = {} local haven_config = { enabled = true, exclusions = { function(path, _) if utils_os.is_windows then return path:lower():starts_with( (vim.fn.eval("$VIMRUNTIME") .. utils_path.directory_sep):lower() ) end return path:starts_with( vim.fn.eval("$VIMRUNTIME") .. utils_path.directory_sep ) end, function(path, _) if utils_os.is_windows then return path:lower():starts_with( (vim.fn.stdpath("data") .. utils_path.directory_sep):lower() ) end return path:starts_with( vim.fn.stdpath("data") .. utils_path.directory_sep ) end, function(path, _) if utils_os.is_windows then return path:lower():starts_with( (vim.fs.joinpath(vim.fn.eval("$XDG_CONFIG_HOME"), "coc") .. utils_path.directory_sep):lower() ) end return path:starts_with( vim.fs.joinpath(vim.fn.eval("$XDG_CONFIG_HOME"), "coc") .. utils_path.directory_sep ) end, function(path, _) if utils_os.is_windows then return path:lower():ends_with( (utils_path.directory_sep .. ".git" .. utils_path.directory_sep .. "COMMIT_EDITMSG"):lower() ) end return path:ends_with( utils_path.directory_sep .. ".git" .. utils_path.directory_sep .. "COMMIT_EDITMSG" ) end, function(path, config) if utils_os.is_windows then return path:lower():starts_with( (config.haven_path .. utils_path.directory_sep):lower() ) end return path:starts_with(config.haven_path .. utils_path.directory_sep) end }, haven_path = vim.fs.joinpath(vim.fn.stdpath("data"), "nvim-haven"), inclusions = {}, max_history_count = 200, save_timeout = 10000 } local line_ending = utils.iff(utils_os.is_windows, "\r\n", "\n") local ns_previewer = vim.api.nvim_create_namespace("telescope.previewers") local print_message = function(is_error, msg) vim.notify( msg, utils.iff(is_error, "error", "info"), { title = "nvim-haven" } ) end local diff_strings = function(a, b) return vim.text.diff(a, b, {algorithm = "minimal"}) end local create_save_file_path = function(buf_info) local encode = function(str) return str:gsub("\r?\n", "\r\n"):gsub( "([^%w%-%.%_%~ ])", function(c) return string.format("%%%02X", string.byte(c)) end ):gsub(" ", "+") end return vim.fs.joinpath( haven_config.haven_path, encode(buf_info.name) .. ".save" ) end local save_change_file = function(buf_info, lines, save_file) active_saves[save_file] = nil local file, err = io.open(save_file, "a") if file == nil then print_message(true, err) return end local file_entry = vim.json.encode( { date = os.time(), ft = plenary_filetype.detect(buf_info.name, {}), lines = lines } ) _, err = file:write(file_entry .. line_ending) if err ~= nil then print_message(true, err) end file:close() end local save_change_file_entries = function(entries, save_file) active_saves[save_file] = nil local file, err = io.open(save_file, "w+") if file == nil then print_message(true, err) return end for _, entry in pairs(entries) do _, err = file:write(vim.json.encode(entry) .. line_ending) if err ~= nil then print_message(true, err) end end file:close() end local read_change_file = function(save_file) local file, err = io.open(save_file, "r") if file == nil then return nil, err end local save_data save_data, err = file:read("a") if err ~= nil then return nil, err end file:close() local entries = vim.json.decode( "[" .. table.concat(save_data:split(line_ending), ",") .. "]" ) if #entries > haven_config.max_history_count then while #entries > haven_config.max_history_count do table.remove(entries, 1) end save_change_file_entries(entries, save_file) end return entries end local process_file_changed = function(buf_info) local save_file = create_save_file_path(buf_info) local changed_data = changed_lookup[save_file] local immediate = vim.fn.filereadable(save_file) == 0 local update_changed_lookup = function() changed_lookup[save_file] = { changed = buf_info.changed, changedtick = buf_info.changedtick } end if not immediate and (changed_data == nil or (buf_info.changed == 0 and changed_data.changed == 0) or buf_info.changedtick == changed_data.changedtick) then update_changed_lookup() return end if active_saves[save_file] ~= nil then active_saves[save_file].timer:stop() active_saves[save_file] = nil end update_changed_lookup() local lines = vim.api.nvim_buf_get_lines(buf_info.bufnr, 0, -1, true) local entries, _ = read_change_file(save_file) if entries ~= nil then if diff_strings( table.concat(entries[#entries].lines, line_ending), table.concat(lines, line_ending) ):len() == 0 then return end entries = nil end local saved = false local do_save = function() if not saved then saved = true save_change_file(buf_info, lines, save_file) end end if immediate then do_save() else active_saves[save_file] = { timer = vim.defer_fn(do_save, haven_config.save_timeout), do_save = do_save } end end local check_requirements = function() if vim.o.modifiable ~= 0 and vim.o.buftype ~= "nofile" then local buf_info = vim.fn.getbufinfo(vim.fn.bufname()) if buf_info ~= nil and #buf_info > 0 then buf_info = buf_info[1] if buf_info.name:len() ~= 0 and vim.fn.filereadable(buf_info.name) ~= 0 then if changed_lookup[create_save_file_path(buf_info)] == nil then for _, is_included in pairs(haven_config.inclusions) do if is_included(buf_info.name, haven_config) then return true, buf_info end end for _, is_excluded in pairs(haven_config.exclusions) do if is_excluded(buf_info.name, haven_config) then return false end end end return true, buf_info end end end return false end local handle_buffer_changed = function() local ok, buf_info = check_requirements() if ok and buf_info ~= nil then process_file_changed(buf_info) end end local handle_vim_leave = function() for _, active in pairs(active_saves) do if active.timer ~= nil then active.timer:stop() end active.do_save() end active_saves = {} changed_lookup = {} end local setup_autocmds = function() local group_id = vim.api.nvim_create_augroup("nvim-haven-internal", {clear = true}) if haven_config.enabled then vim.api.nvim_create_autocmd( "BufEnter", { group = group_id, pattern = "*", callback = handle_buffer_changed } ) vim.api.nvim_create_autocmd( "BufWritePost", { group = group_id, pattern = "*", callback = handle_buffer_changed } ) vim.api.nvim_create_autocmd( "InsertLeave", { group = group_id, pattern = "*", callback = handle_buffer_changed } ) vim.api.nvim_create_autocmd( "TextChanged", { group = group_id, pattern = "*", callback = handle_buffer_changed } ) vim.api.nvim_create_autocmd( "VimLeave", { group = group_id, pattern = "*", callback = handle_vim_leave } ) else vim.api.nvim_del_augroup_by_id(group_id) end end local apply_diff_to_lines = function(diff, source_lines) local diff_lines = diff:split(line_ending) local changes = {} local current_diff for _, line in pairs(diff_lines) do if line:len() > 0 then if line:starts_with("@@") and line:ends_with("@@") then local diff_range = line:sub(3, -1):sub(1, -3):split(" ")[1]:split(",") if #diff_range == 1 then table.insert(diff_range, "1") end local diff_start = math.abs(tonumber(diff_range[1], 10)) local diff_count = tonumber(diff_range[2], 10) if diff_count == 0 then diff_start = diff_start + 1 end current_diff = { diff = {line}, next = diff_start + diff_count, start = diff_start } table.insert(changes, current_diff) elseif current_diff ~= nil then table.insert(current_diff.diff, line) end else current_diff = nil end end local actual_line = 1 local buffer_lines = {} local diff_rows = {} local source_line = 1 for _, change in pairs(changes) do while source_line < change.start do table.insert(buffer_lines, source_lines[source_line]) actual_line = actual_line + 1 source_line = source_line + 1 end table.insert(diff_rows, actual_line) for _, change_diff_lines in pairs(change.diff) do table.insert(buffer_lines, change_diff_lines) actual_line = actual_line + 1 end source_line = change.next end while source_line <= #source_lines do table.insert(buffer_lines, source_lines[source_line]) actual_line = actual_line + 1 source_line = source_line + 1 end return buffer_lines, diff_rows end local show_picker = function(entries) global_state.set_global_key("selected_entry", nil) local jump_state local jump_to_line = function(self, bufnr, lnum) pcall(vim.api.nvim_buf_clear_namespace, bufnr, ns_previewer, 0, -1) if lnum and lnum > 0 then pcall( vim.api.nvim_buf_add_highlight, bufnr, ns_previewer, "TelescopePreviewLine", lnum - 1, 0, -1 ) pcall(vim.api.nvim_win_set_cursor, self.state.winid, {lnum, 0}) vim.api.nvim_buf_call( bufnr, function() vim.cmd "norm! zz" end ) end end local do_forward_jump = function() if jump_state ~= nil and #jump_state.diff_rows > 0 then jump_state.cur = utils.iff( (jump_state.cur + 1) <= #jump_state.diff_rows, jump_state.cur + 1, #jump_state.diff_rows ) jump_to_line( jump_state.self, jump_state.self.state.bufnr, jump_state.diff_rows[jump_state.cur] ) end end local do_reverse_jump = function() if jump_state ~= nil and #jump_state.diff_rows > 0 then jump_state.cur = utils.iff((jump_state.cur - 1) > 0, jump_state.cur - 1, 1) jump_to_line( jump_state.self, jump_state.self.state.bufnr, jump_state.diff_rows[jump_state.cur] ) end end pickers.new( {}, { prompt_title = "File History", previewer = previewers.new_buffer_previewer( { define_preview = function(self, entry) jump_state = nil if entry.index < #entries then local previous_lines = entries[entry.index + 1].lines local buffer_lines, diff_rows = apply_diff_to_lines( diff_strings( table.concat(previous_lines, line_ending), table.concat(entry.value.lines, line_ending) ), previous_lines ) previous_lines = nil vim.api.nvim_buf_set_lines( self.state.bufnr, 0, -1, false, buffer_lines ) previewer_utils.regex_highlighter(self.state.bufnr, "diff") jump_state = {self = self, cur = 0, diff_rows = diff_rows} vim.schedule( function() do_forward_jump() end ) else vim.api.nvim_buf_set_lines( self.state.bufnr, 0, -1, false, entry.value.lines ) previewer_utils.highlighter(self.state.bufnr, entry.value.ft, {}) end end } ), sorter = conf.generic_sorter({}), finder = finders.new_table( { results = entries, entry_maker = function(item) return { value = item, ordinal = tostring(item.date), display = os.date("%m-%d-%Y %H:%M:%S", item.date) } end } ), attach_mappings = function(prompt_bufnr, map) actions.select_default:replace( function() actions.close(prompt_bufnr) local selection = action_state.get_selected_entry() if selection ~= nil then vim.api.nvim_buf_set_lines(0, 0, -1, false, selection.value.lines) end end ) map("i", "", do_forward_jump) map("n", "", do_forward_jump) map("i", "", do_reverse_jump) map("n", "", do_reverse_jump) return true end } ):find() end M.setup = function(config) if config == nil then config = {} end if config.exclusions ~= nil then for _, e in pairs(config.exclusions) do if type(e) ~= "function" then print_message( true, "'exclusions' contains an entry that is not a function. Skipping all exclusions until this is corrected:" ) table.print_table(e) break end table.insert(haven_config.exclusions, e) end end haven_config.enabled = vim.F.if_nil(config.enabled, haven_config.enabled) haven_config.haven_path = vim.F.if_nil(config.haven_path, haven_config.haven_path) if config.inclusions ~= nil then for _, e in pairs(config.inclusions) do if type(e) ~= "function" then print_message( true, "'inclusions' contains an entry that is not a function. Skipping this inclusion until it is corrected:" ) table.print_table(e) end table.insert(haven_config.inclusions, e) end end haven_config.max_history_count = vim.F.if_nil(config.max_history_count, haven_config.max_history_count) if haven_config.max_history_count < 10 then print_message( true, "'max_history_count' too low: " .. haven_config.max_history_count ) haven_config.max_history_count = 100 print_message( true, "reset 'max_history_count': " .. haven_config.max_history_count ) elseif haven_config.max_history_count > 500 then print_message( true, "'max_history_count' too high: " .. haven_config.max_history_count ) haven_config.max_history_count = 500 print_message( true, "reset 'max_history_count': " .. haven_config.max_history_count ) end haven_config.save_timeout = vim.F.if_nil(config.save_timeout, haven_config.save_timeout) if haven_config.save_timeout < 135 then print_message(true, "'save_timeout' too low: " .. haven_config.save_timeout) haven_config.save_timeout = 135 print_message(true, "reset 'save_timeout': " .. haven_config.save_timeout) elseif haven_config.save_timeout > 10000 then print_message( true, "'save_timeout' too high: " .. haven_config.save_timeout ) haven_config.save_timeout = 10000 print_message(true, "reset 'save_timeout': " .. haven_config.save_timeout) end if vim.fn.mkdir(haven_config.haven_path, "p") == 0 then print_message(true, "directory create failed: " .. haven_config.haven_path) haven_config.enabled = false return end if vim.fn.isdirectory(haven_config.haven_path) == 0 then print_message(true, "directory not found: " .. haven_config.haven_path) haven_config.enabled = false return end setup_autocmds() end M.clean = function() local decode = function(str) if str == nil then return "" end return str:gsub("+", " "):gsub( "%%(%x%x)", function(x) return string.char(tonumber(x, 16)) end ) end local history_files = scan.scan_dir(haven_config.haven_path, {hidden = true, depth = 1}) for _, history_file in ipairs(history_files) do local source_path = Path:new( decode(Path:new(history_file):make_relative(haven_config.haven_path)):sub( 1, -6 ) ) if not source_path:exists() then local history_path = Path:new(history_file) print("removing: " .. history_path:absolute()) history_path:rm() end end end M.disable = function() if haven_config.enabled then haven_config.enabled = false setup_autocmds() handle_vim_leave() end end M.enable = function() if not haven_config.enabled then haven_config.enabled = true handle_buffer_changed() setup_autocmds() end end M.history = function(bufname) bufname = vim.F.if_nil(bufname, vim.fn.bufname()) local buf_info = vim.fn.getbufinfo(bufname) if buf_info ~= nil and #buf_info > 0 then buf_info = buf_info[1] local save_file = create_save_file_path(buf_info) if vim.fn.filereadable(save_file) ~= 0 then local entries, err = read_change_file(save_file) if entries == nil then print_message(true, err) return end show_picker(table.reverse(entries)) end end end _G.Nvim_Haven_Clean = M.clean _G.Nvim_Haven_Disable = M.disable _G.Nvim_Haven_Enable = M.enable _G.Nvim_Haven_History = M.history return M