Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 120 additions & 2 deletions lua/marksman/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,32 @@ local default_config = {
ProjectMarksHelp = { fg = "#61AFEF" },
ProjectMarksBorder = { fg = "#5A5F8C" },
ProjectMarksSearch = { fg = "#E5C07B" },
ProjectMarksSeparator = { fg = "#3E4451" },
ProjectMarksSign = { fg = "#61AFEF" },
},
auto_save = true,
max_marks = 100,
silent = false,
minimal = false,
disable_default_keymaps = false,
debounce_ms = 500, -- Debounce save operations
debounce_ms = 100, -- Debounce save operations
ui = {
-- Position of the marks window.
-- "center" positions the window in the middle of the editor (default).
-- "top_center" aligns the window at the top of the screen, centered horizontally.
-- "bottom_center" aligns the window at the bottom of the screen, centered horizontally.
position = "center",
},

-- Sign column settings for displaying marks in the left gutter. When
-- `enabled` is true, Marksman will place a sign next to each mark in
-- its associated buffer. Users can customize the text displayed in
-- the sign column (`text`) and the sign priority (`priority`).
sign = {
enabled = false,
text = "●",
priority = 50,
},
}

-- Configuration validation schema
Expand All @@ -54,7 +66,7 @@ local config_schema = {
silent = { type = "boolean" },
minimal = { type = "boolean" },
disable_default_keymaps = { type = "boolean" },
debounce_ms = { type = "number", min = 100, max = 5000 },
debounce_ms = { type = "number", min = 50, max = 5000 },
ui = {
type = "table",
fields = {
Expand All @@ -64,13 +76,25 @@ local config_schema = {
},
},
},

sign = {
type = "table",
fields = {
enabled = { type = "boolean" },
text = { type = "string" },
priority = { type = "number" },
},
},
}

local config = {}

-- Debounced save timer
local save_timer = nil

-- Forward declaration for sign updates (defined below)
local update_signs

---Helper function for conditional notifications
---@param message string The notification message
---@param level number The log level (vim.log.levels.*)
Expand Down Expand Up @@ -105,6 +129,27 @@ local function validate_config(user_config, schema)
string.format("Config value %s above maximum: %s > %s", key, value, rule.max),
vim.log.levels.WARN
)
elseif rule.allowed then
local allowed = false
for _, v in ipairs(rule.allowed) do
if value == v then
allowed = true
break
end
end
if not allowed then
notify(
string.format(
"Invalid value for %s: %s (allowed: %s)",
key,
value,
table.concat(rule.allowed, ", ")
),
vim.log.levels.WARN
)
else
validated[key] = value
end
else
validated[key] = value
end
Expand Down Expand Up @@ -175,6 +220,53 @@ local function debounced_save()
end, config.debounce_ms or 500)
end

---Define and place sign indicators for marksman marks. This function clears
---existing signs in the "marksman" group and places a sign at each mark
---location in its loaded buffer. It respects the user's sign configuration
---(enabled, text, highlight, priority). Called internally after mark
---mutations and on relevant autocommands.
update_signs = function()
-- Only proceed if sign column support is enabled
if not config.sign or not config.sign.enabled then
return
end
-- Define the sign used for marksman marks. Use pcall to handle
-- environments where sign definitions may already exist.
local sign_name = "MarksmanSign"
local ok_define = pcall(vim.fn.sign_define, sign_name, {
text = config.sign.text or "●",
texthl = "ProjectMarksSign",
linehl = "",
numhl = "",
})
-- Clear any previously placed signs in the marksman group
pcall(vim.fn.sign_unplace, "marksman", { buffer = "*" })
vim.api.nvim_exec2("redraw!", {})
-- Fetch marks
local storage_module = get_storage()
if not storage_module then
return
end
local marks = storage_module.get_marks()
local id = 1
for _, mark in pairs(marks) do
-- Place sign only if buffer is loaded
local bufnr = vim.fn.bufnr(mark.file, false)
if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then
pcall(vim.fn.sign_place, id, "marksman", sign_name, bufnr, {
lnum = mark.line,
priority = config.sign.priority or 10,
})
id = id + 1
end
end
end

-- Expose update_signs so tests and users can trigger sign updates manually
M.update_signs = function()
update_signs()
end

---Add a mark at the current cursor position
---@param name string? Optional mark name (auto-generated if nil)
---@param description string? Optional mark description
Expand Down Expand Up @@ -235,6 +327,8 @@ function M.add_mark(name, description)
local success = storage_module.add_mark(name, mark)
if success then
debounced_save()
-- Update signs after successfully adding a mark
update_signs()
notify("󰃀 Mark added: " .. name, vim.log.levels.INFO)
return { success = true, message = "Mark added successfully", mark_name = name }
else
Expand Down Expand Up @@ -408,6 +502,8 @@ function M.delete_mark(name)
local success = storage_module.delete_mark(name)
if success then
debounced_save()
-- Update signs after deleting a mark
update_signs()
notify("󰃀 Mark deleted: " .. name, vim.log.levels.INFO)
return { success = true, message = "Mark deleted successfully", mark_name = name }
else
Expand Down Expand Up @@ -436,6 +532,8 @@ function M.rename_mark(old_name, new_name)
local success = storage_module.rename_mark(old_name, new_name)
if success then
debounced_save()
-- Update signs after renaming a mark
update_signs()
notify("󰃀 Mark renamed: " .. old_name .. " → " .. new_name, vim.log.levels.INFO)
return { success = true, message = "Mark renamed successfully", old_name = old_name, new_name = new_name }
else
Expand All @@ -460,6 +558,8 @@ function M.move_mark(name, direction)
local success = storage_module.move_mark(name, direction)
if success then
debounced_save()
-- Re-apply signs after moving mark order (even if line doesn't change)
update_signs()
notify("󰃀 Mark moved " .. direction, vim.log.levels.INFO)
return { success = true, message = "Mark moved successfully", direction = direction }
else
Expand Down Expand Up @@ -544,6 +644,8 @@ function M.clear_all_marks()
if storage_module then
storage_module.clear_all_marks()
debounced_save()
-- Remove all signs after clearing marks
update_signs()
notify("󰃀 All marks cleared", vim.log.levels.INFO)
end
end
Expand Down Expand Up @@ -743,6 +845,22 @@ function M.setup(opts)
callback = M.cleanup,
desc = "Cleanup marksman resources",
})

-- If sign column support is enabled, place initial signs and set up autocommands
if config.sign and config.sign.enabled then
-- Place signs for marks that already exist
pcall(update_signs)
-- Update signs whenever a buffer is read or entered (marks in that file may need signs)
vim.api.nvim_create_autocmd({ "BufReadPost", "BufWinEnter" }, {
callback = function()
-- Delay slightly to ensure buffer is loaded
vim.schedule(function()
pcall(update_signs)
end)
end,
desc = "Update marksman signs when entering a buffer",
})
end
end

return M
42 changes: 39 additions & 3 deletions lua/marksman/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ local function setup_highlights()
ProjectMarksBorder = { fg = "#5A5F8C" },
ProjectMarksSearch = { fg = "#E5C07B" },
ProjectMarksSeparator = { fg = "#3E4451" }, -- For separator line
-- Highlight for the sign indicator shown next to marks from the current file.
-- This colour defaults to the same as the title but can be overridden in the user's config.
ProjectMarksSign = { fg = "#61AFEF" },
}

-- Merge with user config
Expand Down Expand Up @@ -314,6 +317,9 @@ local function create_marks_content(marks, search_query)
local footer_height = 2 -- Separator + help line
local available_height = WINDOW_HEIGHT - header_height - footer_height - 2

-- Determine current file (absolute path) to mark entries belonging to the active buffer
local current_file = vim.fn.expand("%:p")

-- Handle no marks case
if shown_marks == 0 then
local no_marks_line = search_query and search_query ~= "" and " No marks found matching search"
Expand Down Expand Up @@ -342,6 +348,25 @@ local function create_marks_content(marks, search_query)
local line_idx = current_line + marks_added
local line, line_highlights = create_minimal_mark_line(name, mark, i, line_idx)

-- Determine if this mark is in the current file
if mark.file == current_file then
-- Replace leading space with sign indicator
line = config.sign.text .. " " .. line:sub(2)
for _, hl in ipairs(line_highlights) do
hl.col = hl.col + 1
if hl.end_col ~= -1 then
hl.end_col = hl.end_col + 1
end
end
-- Add highlight for the sign indicator
table.insert(highlights, {
line = line_idx,
col = 0,
end_col = 1,
hl_group = "ProjectMarksSign",
})
end

table.insert(lines, line)
mark_info[line_idx] = { name = name, mark = mark, index = i }

Expand All @@ -362,10 +387,22 @@ local function create_marks_content(marks, search_query)
local start_line_idx = current_line + marks_added
local mark_lines, mark_highlights = create_detailed_mark_lines(name, mark, i, start_line_idx)

-- If mark is in current file, decorate the first line with a sign indicator
if mark.file == current_file then
mark_lines[1] = config.sign.text .. mark_lines[1]:sub(2)
-- Insert highlight for sign indicator
table.insert(highlights, {
line = start_line_idx,
col = 0,
end_col = 1,
hl_group = "ProjectMarksSign",
})
end

mark_info[start_line_idx] = { name = name, mark = mark, index = i }

for _, line in ipairs(mark_lines) do
table.insert(lines, line)
for _, line_content in ipairs(mark_lines) do
table.insert(lines, line_content)
end
for _, hl in ipairs(mark_highlights) do
table.insert(highlights, hl)
Expand Down Expand Up @@ -736,4 +773,3 @@ function M.cleanup()
end

return M