feat: bug fixes, lazy.nvim-style controls, auto-update checking, repo polish

Phase 1 - Bug fixes:
- Fix extmark rendering in text.lua (table vs string hl handling)
- Fix timer leak: WinClosed autocmd now triggers view.close()
- Add GOBIN/GOPATH/~/go/bin awareness to is_installed() and new bin_path()
- Reject update_all() while tasks running (race condition fix)
- Implement _toggle_details with full binary info expansion

Phase 2 - Lazy.nvim-style controls:
- U = Update all, u = update cursor binary
- I = Install all missing, i = install cursor binary
- x = Abort, CR = toggle details, q/Esc = close
- Line-to-binary mapping for cursor-aware actions

Phase 3 - Auto-update checking:
- New checker module with go list/go version -m integration
- auto_check config option (daily/weekly/custom frequency)
- Persistent state in stdpath('data')/glaze/state.json
- :GlazeCheck command for manual checks
- Update indicators in UI (version info + arrows)

Phase 4 - Repo polish:
- MIT LICENSE file
- Doughnut-themed README with badges and Why Glaze? section
- Updated help docs with all new features/keybinds/API
This commit is contained in:
2026-02-19 00:47:19 +00:00
parent 60c2fd8739
commit cd2571d3f2
9 changed files with 674 additions and 48 deletions

257
lua/glaze/checker.lua Normal file
View File

@@ -0,0 +1,257 @@
---@brief [[
--- glaze.nvim update checker
--- Checks for newer versions of registered Go binaries
---@brief ]]
local M = {}
---@class GlazeUpdateInfo
---@field name string Binary name
---@field installed_version? string Currently installed version
---@field latest_version? string Latest available version
---@field has_update boolean Whether an update is available
---@type table<string, GlazeUpdateInfo>
M._update_info = {}
---@type boolean
M._checking = false
local STATE_FILE = vim.fn.stdpath("data") .. "/glaze/state.json"
---Read persisted state from disk.
---@return table
local function read_state()
local ok, content = pcall(vim.fn.readfile, STATE_FILE)
if not ok or #content == 0 then
return {}
end
local decode_ok, data = pcall(vim.json.decode, table.concat(content, "\n"))
if not decode_ok then
return {}
end
return data or {}
end
---Write state to disk.
---@param state table
local function write_state(state)
local dir = vim.fn.fnamemodify(STATE_FILE, ":h")
vim.fn.mkdir(dir, "p")
local json = vim.json.encode(state)
vim.fn.writefile({ json }, STATE_FILE)
end
---Get the frequency in seconds from config.
---@return number seconds
local function get_frequency_seconds()
local glaze = require("glaze")
local freq = glaze.config.auto_check.frequency
if freq == "daily" then
return 86400
elseif freq == "weekly" then
return 604800
elseif type(freq) == "number" then
return freq * 3600
end
return 86400
end
---Get installed version of a binary by parsing `go version -m` output.
---@param name string Binary name
---@param callback fun(version: string?)
local function get_installed_version(name, callback)
local glaze = require("glaze")
local bin_path = glaze.bin_path(name)
if not bin_path then
callback(nil)
return
end
vim.fn.jobstart({ "go", "version", "-m", bin_path }, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data then
callback(nil)
return
end
local output = table.concat(data, "\n")
-- Parse "mod\tmodule/path\tv1.2.3\th1:..." or "path\tmodule/path"
local version = output:match("\tmod\t[^\t]+\t(v[^\t%s]+)")
or output:match("\tpath\t[^\n]+\n[^\t]*\tmod\t[^\t]+\t(v[^\t%s]+)")
callback(version)
end,
on_exit = function(_, code)
if code ~= 0 then
callback(nil)
end
end,
})
end
---Check for the latest version of a module using go list.
---@param url string Module URL
---@param callback fun(version: string?)
local function get_latest_version(url, callback)
local glaze = require("glaze")
local cmd = vim.list_extend({}, glaze.config.go_cmd)
vim.list_extend(cmd, { "list", "-m", "-json", url .. "@latest" })
vim.fn.jobstart(cmd, {
stdout_buffered = true,
env = { GOFLAGS = "" },
on_stdout = function(_, data)
if not data then
callback(nil)
return
end
local output = table.concat(data, "\n")
local decode_ok, result = pcall(vim.json.decode, output)
if decode_ok and result and result.Version then
callback(result.Version)
else
callback(nil)
end
end,
on_exit = function(_, code)
if code ~= 0 then
callback(nil)
end
end,
})
end
---Get cached update info.
---@return table<string, GlazeUpdateInfo>
function M.get_update_info()
return M._update_info
end
---Check for updates on all registered binaries.
---@param opts? { silent?: boolean }
function M.check(opts)
opts = opts or {}
local glaze = require("glaze")
local binaries = glaze.binaries()
if vim.tbl_count(binaries) == 0 then
if not opts.silent then
vim.notify("Glaze: no binaries registered", vim.log.levels.INFO)
end
return
end
if M._checking then
if not opts.silent then
vim.notify("Glaze: already checking for updates", vim.log.levels.INFO)
end
return
end
M._checking = true
local remaining = 0
local updates_found = 0
for name, binary in pairs(binaries) do
remaining = remaining + 2 -- installed version + latest version
local info = {
name = name,
installed_version = nil,
latest_version = nil,
has_update = false,
}
M._update_info[name] = info
get_installed_version(name, function()
-- callback receives version from the jobstart; re-check via closure
end)
end
-- Simplified: check each binary sequentially-ish
remaining = vim.tbl_count(binaries)
for name, binary in pairs(binaries) do
local info = M._update_info[name]
get_installed_version(name, function(installed)
info.installed_version = installed
get_latest_version(binary.url, function(latest)
info.latest_version = latest
if installed and latest and installed ~= latest then
info.has_update = true
updates_found = updates_found + 1
end
remaining = remaining - 1
if remaining <= 0 then
M._checking = false
-- Save check timestamp
local state = read_state()
state.last_check = os.time()
state.update_info = {}
for n, i in pairs(M._update_info) do
state.update_info[n] = {
installed_version = i.installed_version,
latest_version = i.latest_version,
has_update = i.has_update,
}
end
write_state(state)
if not opts.silent then
if updates_found > 0 then
vim.schedule(function()
vim.notify("Glaze: " .. updates_found .. " update(s) available", vim.log.levels.INFO)
end)
else
vim.schedule(function()
vim.notify("Glaze: all binaries up to date", vim.log.levels.INFO)
end)
end
elseif updates_found > 0 then
vim.schedule(function()
vim.notify("Glaze: " .. updates_found .. " update(s) available", vim.log.levels.INFO)
end)
end
-- Refresh UI if open
vim.schedule(function()
local ok, view = pcall(require, "glaze.view")
if ok and view._float and view._float:valid() then
view.render()
end
end)
end
end)
end)
end
end
---Auto-check if enough time has passed since last check.
function M.auto_check()
local state = read_state()
local last_check = state.last_check or 0
local now = os.time()
local freq = get_frequency_seconds()
-- Load cached update info
if state.update_info then
for name, info in pairs(state.update_info) do
M._update_info[name] = {
name = name,
installed_version = info.installed_version,
latest_version = info.latest_version,
has_update = info.has_update or false,
}
end
end
if (now - last_check) >= freq then
M.check({ silent = true })
end
end
return M

View File

@@ -119,12 +119,18 @@ function Float:mount()
self:close()
end, "Close")
-- Auto-close on WinClosed
-- Auto-close on WinClosed — also trigger view cleanup to stop timer
vim.api.nvim_create_autocmd("WinClosed", {
pattern = tostring(self.win),
once = true,
callback = function()
self:close()
-- Trigger view close to clean up timer
local ok, view = pcall(require, "glaze.view")
if ok then
view.close()
else
self:close()
end
end,
})

View File

@@ -18,10 +18,15 @@ local M = {}
---@field plugin? string Plugin that registered this binary
---@field callback? fun(success: boolean) Optional callback after install/update
---@class GlazeAutoCheckConfig
---@field enabled boolean Whether to auto-check for updates
---@field frequency string|number Frequency: "daily", "weekly", or hours as number
---@class GlazeConfig
---@field ui GlazeUIConfig
---@field concurrency number Max parallel installations
---@field go_cmd string[] Go command (supports goenv)
---@field auto_check GlazeAutoCheckConfig
---@class GlazeUIConfig
---@field border string Border style
@@ -50,6 +55,10 @@ M.config = {
},
concurrency = 4,
go_cmd = { "go" },
auto_check = {
enabled = true,
frequency = "daily",
},
}
---@type table<string, GlazeBinary>
@@ -100,6 +109,17 @@ function M.setup(opts)
return vim.tbl_keys(M._binaries)
end,
})
vim.api.nvim_create_user_command("GlazeCheck", function()
require("glaze.checker").check()
end, { desc = "Check for binary updates" })
-- Auto-check for updates
if M.config.auto_check.enabled then
vim.defer_fn(function()
require("glaze.checker").auto_check()
end, 3000)
end
end
---Register a binary for management.
@@ -129,10 +149,79 @@ function M.binaries()
end
---Check if a binary is installed.
---Checks PATH, $GOBIN, $GOPATH/bin, and $(go env GOBIN).
---@param name string Binary name
---@return boolean
function M.is_installed(name)
return vim.fn.executable(name) == 1
-- Check PATH first
if vim.fn.executable(name) == 1 then
return true
end
-- Check $GOBIN
local gobin = os.getenv("GOBIN")
if gobin and gobin ~= "" then
local path = gobin .. "/" .. name
if vim.uv.fs_stat(path) then
return true
end
end
-- Check $GOPATH/bin
local gopath = os.getenv("GOPATH")
if gopath and gopath ~= "" then
local path = gopath .. "/bin/" .. name
if vim.uv.fs_stat(path) then
return true
end
end
-- Check default ~/go/bin
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
local default_path = home .. "/go/bin/" .. name
if vim.uv.fs_stat(default_path) then
return true
end
return false
end
---Get the install path for a binary if found.
---@param name string Binary name
---@return string? path Full path to the binary, or nil
function M.bin_path(name)
-- Check PATH
local which = vim.fn.exepath(name)
if which ~= "" then
return which
end
-- Check $GOBIN
local gobin = os.getenv("GOBIN")
if gobin and gobin ~= "" then
local path = gobin .. "/" .. name
if vim.uv.fs_stat(path) then
return path
end
end
-- Check $GOPATH/bin
local gopath = os.getenv("GOPATH")
if gopath and gopath ~= "" then
local path = gopath .. "/bin/" .. name
if vim.uv.fs_stat(path) then
return path
end
end
-- Check default ~/go/bin
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
local default_path = home .. "/go/bin/" .. name
if vim.uv.fs_stat(default_path) then
return default_path
end
return nil
end
---Get binary installation status.

View File

@@ -139,6 +139,12 @@ end
local function run(names, mode)
local glaze = require("glaze")
-- Reject if already running (race condition fix)
if M._running then
vim.notify("Glaze: tasks already running. Wait or abort first.", vim.log.levels.WARN)
return
end
-- Check for Go
local go_check = glaze.config.go_cmd[1]
if vim.fn.executable(go_check) ~= 1 then

View File

@@ -57,6 +57,17 @@ function Text:append(str, hl, opts)
return self
end
---Append a virtual text extmark on the current line (empty string segment).
---@param extmark GlazeExtmark Extmark options (virt_text, virt_text_win_col, etc.)
---@return GlazeText
function Text:append_extmark(extmark)
if #self._lines == 0 then
self:nl()
end
table.insert(self._lines[#self._lines], { str = "", hl = extmark })
return self
end
---@return GlazeText
function Text:nl()
table.insert(self._lines, {})
@@ -106,7 +117,7 @@ function Text:render(buf, ns)
vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1)
for l, line in ipairs(self._lines) do
if lines[l] ~= "" then
if lines[l] ~= "" or true then -- process even empty lines for extmarks
local col = self.padding
for _, segment in ipairs(line) do
local width = vim.fn.strwidth(segment.str)
@@ -114,12 +125,23 @@ function Text:render(buf, ns)
if extmark then
if type(extmark) == "string" then
extmark = { hl_group = extmark, end_col = col + width }
-- Simple highlight group string
if width > 0 then
pcall(vim.api.nvim_buf_set_extmark, buf, ns, l - 1, col, {
hl_group = extmark,
end_col = col + width,
})
end
elseif type(extmark) == "table" then
-- Full extmark table (virt_text, etc.)
local extmark_col = extmark.col or col
local opts = vim.tbl_extend("force", {}, extmark)
opts.col = nil -- col is positional, not an extmark option
if not opts.end_col and width > 0 and opts.hl_group then
opts.end_col = extmark_col + width
end
pcall(vim.api.nvim_buf_set_extmark, buf, ns, l - 1, extmark_col, opts)
end
---@cast extmark GlazeExtmark
local extmark_col = extmark.col or col
extmark.col = nil
pcall(vim.api.nvim_buf_set_extmark, buf, ns, l - 1, extmark_col, extmark)
end
col = col + width
end

View File

@@ -11,9 +11,26 @@ M._float = nil
---@type number?
M._timer = nil
---@type table<string, boolean>
M._expanded = {}
---Line-to-binary mapping for cursor-aware actions.
---@type table<number, string>
M._line_map = {}
local SPINNERS = { "", "", "", "" }
local SPINNER_IDX = 1
---Get the binary name under the cursor.
---@return string? name Binary name or nil
function M._get_cursor_binary()
if not M._float or not M._float:valid() then
return nil
end
local line = vim.api.nvim_win_get_cursor(M._float.win)[1]
return M._line_map[line]
end
---Open the Glaze UI.
function M.open()
local Float = require("glaze.float").Float
@@ -23,17 +40,35 @@ function M.open()
end
M._float = Float.new({
title = " 󱓞 Glaze ",
title = " 🍩 Glaze ",
})
-- Set up keymaps
M._float:map("u", function()
-- Set up keymaps — lazy.nvim-style controls
M._float:map("U", function()
require("glaze.runner").update_all()
end, "Update all")
end, "Update all binaries")
M._float:map("u", function()
local name = M._get_cursor_binary()
if name then
require("glaze.runner").update({ name })
else
vim.notify("Move cursor to a binary line to update it", vim.log.levels.INFO)
end
end, "Update binary under cursor")
M._float:map("i", function()
local name = M._get_cursor_binary()
if name then
require("glaze.runner").install({ name })
else
vim.notify("Move cursor to a binary line to install it", vim.log.levels.INFO)
end
end, "Install binary under cursor")
M._float:map("I", function()
require("glaze.runner").install_missing()
end, "Install missing")
end, "Install all missing")
M._float:map("x", function()
require("glaze.runner").abort()
@@ -58,6 +93,7 @@ end
function M._start_timer()
if M._timer then
vim.fn.timer_stop(M._timer)
M._timer = nil
end
M._timer = vim.fn.timer_start(100, function()
@@ -80,8 +116,14 @@ function M._start_timer()
end
---@private
---Toggle detail expansion for the binary under the cursor.
function M._toggle_details()
-- TODO: Implement detail expansion
local name = M._get_cursor_binary()
if not name then
return
end
M._expanded[name] = not M._expanded[name]
M.render()
end
---Render the UI.
@@ -92,6 +134,7 @@ function M.render()
local glaze = require("glaze")
local runner = require("glaze.runner")
local checker = require("glaze.checker")
local Text = require("glaze.text").Text
local text = Text.new()
@@ -99,9 +142,12 @@ function M.render()
local icons = glaze.config.ui.icons
-- Reset line map
M._line_map = {}
-- Header
text:nl()
text:append(" ", "GlazeIcon"):append("Glaze", "GlazeH1"):append(" Go Binary Manager", "GlazeComment"):nl()
text:append(" 🍩 ", "GlazeIcon"):append("Glaze", "GlazeH1"):append(" Go Binary Manager", "GlazeComment"):nl()
text:nl()
-- Stats / Progress
@@ -113,12 +159,19 @@ function M.render()
-- Keybinds
text:append(" ", nil, { indent = 2 })
text:append(" U ", "GlazeButtonActive"):append(" Update All ", "GlazeButton")
text:append(" ")
text:append(" u ", "GlazeButtonActive"):append(" Update ", "GlazeButton")
text:append(" ")
text:append(" i ", "GlazeButtonActive"):append(" Install ", "GlazeButton")
text:append(" I ", "GlazeButtonActive"):append(" Install All ", "GlazeButton")
text:append(" ")
text:append(" i ", "GlazeButtonActive"):append(" Install ", "GlazeButton")
text:nl()
text:append(" ", nil, { indent = 2 })
text:append(" x ", "GlazeButtonActive"):append(" Abort ", "GlazeButton")
text:append(" ")
text:append("", "GlazeButtonActive"):append(" Details ", "GlazeButton")
text:append(" ")
text:append(" q ", "GlazeButtonActive"):append(" Close ", "GlazeButton")
text:nl():nl()
@@ -137,7 +190,22 @@ function M.render()
local binaries = glaze.binaries()
local binary_count = vim.tbl_count(binaries)
text:append("Binaries", "GlazeH2"):append(" (" .. binary_count .. ")", "GlazeComment"):nl()
-- Count updates available
local updates_available = 0
local update_info = checker.get_update_info()
for _ in pairs(update_info) do
updates_available = updates_available + 1
end
local header_suffix = " (" .. binary_count .. ")"
if updates_available > 0 then
header_suffix = header_suffix
end
text:append("Binaries", "GlazeH2"):append(header_suffix, "GlazeComment")
if updates_available > 0 then
text:append(" " .. updates_available .. " update(s) available", "GlazeRunning")
end
text:nl()
text:nl()
if binary_count == 0 then
@@ -156,7 +224,7 @@ function M.render()
end)
for _, item in ipairs(sorted) do
M._render_binary(text, item.binary, icons)
M._render_binary(text, item.binary, icons, update_info)
end
end
@@ -181,14 +249,19 @@ function M._render_progress(text, stats)
local done_width = math.floor(done_ratio * width + 0.5)
if stats.done < stats.total then
text:append("", {
text:append_extmark({
virt_text_win_col = 2,
virt_text = { { string.rep("", done_width), "GlazeProgressDone" } },
})
text:append("", {
text:append_extmark({
virt_text_win_col = 2 + done_width,
virt_text = { { string.rep("", width - done_width), "GlazeProgressTodo" } },
})
else
text:append_extmark({
virt_text_win_col = 2,
virt_text = { { string.rep("", width), "GlazeProgressDone" } },
})
end
end
@@ -208,6 +281,9 @@ function M._render_task(text, task, icons)
icon, icon_hl = icons.pending, "GlazePending"
end
local line_num = text:row()
M._line_map[line_num] = task.binary.name
text:append(" " .. icon .. " ", icon_hl)
text:append(task.binary.name, "GlazeBinary")
@@ -230,14 +306,18 @@ end
---@param text GlazeText
---@param binary GlazeBinary
---@param icons GlazeIcons
---@param update_info? table<string, GlazeUpdateInfo>
---@private
function M._render_binary(text, binary, icons)
function M._render_binary(text, binary, icons, update_info)
local glaze = require("glaze")
local installed = glaze.is_installed(binary.name)
local icon = installed and icons.done or icons.pending
local icon_hl = installed and "GlazeIconDone" or "GlazePending"
local line_num = text:row()
M._line_map[line_num] = binary.name
text:append(" " .. icon .. " ", icon_hl)
text:append(binary.name, "GlazeBinary")
@@ -245,11 +325,54 @@ function M._render_binary(text, binary, icons)
text:append(" (" .. binary.plugin .. ")", "GlazePlugin")
end
-- Show update available indicator
if update_info and update_info[binary.name] then
local info = update_info[binary.name]
if info.has_update then
text:append("", "GlazeRunning")
if info.installed_version and info.latest_version then
text:append(" " .. info.installed_version .. "" .. info.latest_version, "GlazeTime")
end
elseif info.installed_version then
text:append("" .. info.installed_version, "GlazeVersion")
end
end
text:nl()
text:append(binary.url, "GlazeUrl", { indent = 6 }):nl()
-- Expanded details
if M._expanded[binary.name] then
text:append("URL: ", "GlazeComment", { indent = 6 })
text:append(binary.url, "GlazeUrl"):nl()
local bin_path = glaze.bin_path(binary.name)
if bin_path then
text:append("Path: ", "GlazeComment", { indent = 6 })
text:append(bin_path, "GlazeUrl"):nl()
end
if binary.plugin then
text:append("Plugin: ", "GlazeComment", { indent = 6 })
text:append(binary.plugin, "GlazePlugin"):nl()
end
-- Show last error output from tasks
local runner = require("glaze.runner")
for _, task in ipairs(runner.tasks()) do
if task.binary.name == binary.name and task.status == "error" and #task.output > 0 then
text:append("Error: ", "GlazeError", { indent = 6 }):nl()
for _, line in ipairs(task.output) do
text:append(line, "GlazeError", { indent = 8 }):nl()
end
break
end
end
text:nl()
end
end
---Close the UI.
---Close the UI and clean up timer.
function M.close()
if M._timer then
vim.fn.timer_stop(M._timer)