diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e96918 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tai Groot + +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. diff --git a/README.md b/README.md index 86a62eb..1d7c65b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,33 @@ -# glaze.nvim 󱓞 +# 🍩 glaze.nvim -> A **Mason/Lazy-style** interface for managing Go binaries across Neovim plugins. +> **Go + Lazy = Glaze** — A Mason/Lazy-style manager for Go binaries in Neovim. > Charmbracelet-inspired aesthetic. Zero duplication. One source of truth. +> +> *Like a fresh doughnut glaze — smooth, sweet, and holds everything together.* ![Lua](https://img.shields.io/badge/Lua-2C2D72?style=flat&logo=lua&logoColor=white) ![Neovim](https://img.shields.io/badge/Neovim%200.9+-57A143?style=flat&logo=neovim&logoColor=white) +![Go Required](https://img.shields.io/badge/Go-required-00ADD8?style=flat&logo=go&logoColor=white) +![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) + +## 🤔 Why Glaze? + +Every Go-based Neovim plugin reinvents the wheel: each one ships its own +`go install` wrapper, its own update command, its own version checking. + +**Glaze stops the madness.** Register your binaries once, manage them all from +one beautiful UI. Plugin authors get a two-line integration. Users get a single +`:Glaze` command. ## ✨ Features - **Centralized binary management** — Register binaries from any plugin, update them all at once -- **Lazy.nvim-style UI** — Floating window with progress bars, spinners, and status indicators +- **Lazy.nvim-style UI** — Floating window with progress bars, spinners, and status indicators +- **Cursor-aware keybinds** — `u` updates the binary under your cursor, `U` updates all - **Parallel installations** — Configurable concurrency for fast updates +- **Auto-update checking** — Daily/weekly checks with non-intrusive notifications +- **GOBIN/GOPATH awareness** — Finds binaries even if not in PATH +- **Detail expansion** — Press `` to see URL, install path, version info - **Charmbracelet aesthetic** — Pink/magenta color scheme that matches the Charm toolchain - **Zero config for dependents** — Just register and go - **Callback support** — Get notified when your binary is updated @@ -51,26 +68,30 @@ glaze.register("mods", "github.com/charmbracelet/mods") | `:Glaze` | Open the Glaze UI | | `:GlazeUpdate [name]` | Update all or specific binary | | `:GlazeInstall [name]` | Install missing or specific binary | +| `:GlazeCheck` | Manually check for available updates | ### Keybinds (in Glaze UI) | Key | Action | |-----|--------| -| `u` | Update all binaries | -| `i` | Install missing binaries | +| `U` | Update ALL binaries | +| `u` | Update binary under cursor | +| `I` | Install all missing binaries | +| `i` | Install binary under cursor | | `x` | Abort running tasks | +| `` | Toggle details (URL, path, version) | | `q` / `` | Close window | ## 🔌 For Plugin Authors -Register your plugin's binaries as a dependency: +Register your plugin's binaries as a dependency — two lines is all it takes: ```lua -- In your plugin's setup or init: local ok, glaze = pcall(require, "glaze") if ok then glaze.register("mytool", "github.com/me/mytool", { - plugin = "myplugin.nvim", -- Shows in UI + plugin = "myplugin.nvim", callback = function(success) if success then vim.notify("mytool updated!") @@ -86,7 +107,6 @@ You can still expose plugin-specific commands that delegate to Glaze: ```lua vim.api.nvim_create_user_command("MyPluginUpdate", function() - local glaze = require("glaze") require("glaze.runner").update({ "mytool" }) end, {}) ``` @@ -108,6 +128,10 @@ require("glaze").setup({ }, concurrency = 4, -- Max parallel installations go_cmd = { "go" }, -- Auto-detects goenv if available + auto_check = { + enabled = true, -- Auto-check for updates + frequency = "daily", -- "daily", "weekly", or hours as number + }, }) ``` @@ -139,7 +163,8 @@ glaze.unregister(name) -- Remove a binary glaze.binaries() -- Get all registered binaries -- Status -glaze.is_installed(name) -- Check if binary exists +glaze.is_installed(name) -- Check if binary exists (PATH + GOBIN + GOPATH) +glaze.bin_path(name) -- Get full path to binary glaze.status(name) -- "installed", "missing", or "unknown" -- Runner (for programmatic control) @@ -152,15 +177,22 @@ runner.abort() -- Stop all tasks runner.is_running() -- Check if tasks are running runner.tasks() -- Get current task list runner.stats() -- Get { total, done, error, running, pending } + +-- Update checker +local checker = require("glaze.checker") +checker.check() -- Check for updates (with notifications) +checker.auto_check() -- Check only if enough time has passed +checker.get_update_info() -- Get cached update info ``` ## 🤝 Related Projects - [freeze.nvim](https://github.com/taigrr/freeze.nvim) — Screenshot code with freeze - [neocrush.nvim](https://github.com/taigrr/neocrush.nvim) — AI-powered coding assistant +- [blast.nvim](https://github.com/taigrr/blast.nvim) — Code activity tracking - [lazy.nvim](https://github.com/folke/lazy.nvim) — UI inspiration - [mason.nvim](https://github.com/williamboman/mason.nvim) — Concept inspiration ## 📄 License -MIT +MIT © [Tai Groot](https://github.com/taigrr) diff --git a/doc/glaze.txt b/doc/glaze.txt index e18b39d..27e3a29 100644 --- a/doc/glaze.txt +++ b/doc/glaze.txt @@ -1,4 +1,4 @@ -*glaze.txt* Centralized Go binary management for Neovim plugins +*glaze.txt* 🍩 Centralized Go binary management for Neovim plugins Author: Tai Groot License: MIT @@ -10,19 +10,25 @@ CONTENTS *glaze-contents* 1. Introduction ........................... |glaze-introduction| 2. Setup .................................. |glaze-setup| 3. Commands ............................... |glaze-commands| - 4. API .................................... |glaze-api| - 5. Configuration .......................... |glaze-config| - 6. Highlights ............................. |glaze-highlights| - 7. For Plugin Authors ..................... |glaze-plugin-authors| + 4. Keybinds ............................... |glaze-keybinds| + 5. API .................................... |glaze-api| + 6. Configuration .......................... |glaze-config| + 7. Highlights ............................. |glaze-highlights| + 8. Auto-update Checking ................... |glaze-auto-check| + 9. For Plugin Authors ..................... |glaze-plugin-authors| ============================================================================== 1. INTRODUCTION *glaze-introduction* -Glaze is a centralized manager for Go binaries used by Neovim plugins. Instead -of each plugin implementing its own binary installer, Glaze provides: +Glaze (Go + Lazy = Glaze 🍩) is a centralized manager for Go binaries used by +Neovim plugins. Instead of each plugin implementing its own binary installer, +Glaze provides: -- A unified UI for managing all Go binaries -- Parallel installation with progress tracking +- A unified lazy.nvim-style UI for managing all Go binaries +- Parallel installation with progress tracking +- Cursor-aware keybinds for individual binary control +- Automatic update checking with notifications +- GOBIN/GOPATH awareness - A simple registration API for plugin authors ============================================================================== @@ -50,8 +56,26 @@ Using lazy.nvim: *:GlazeInstall* :GlazeInstall [name] Install missing binaries, or a specific one. + *:GlazeCheck* +:GlazeCheck Manually check for available updates. + ============================================================================== -4. API *glaze-api* +4. KEYBINDS *glaze-keybinds* + +These keybinds are active in the Glaze UI window: + + Key Action ~ + `U` Update ALL registered binaries + `u` Update the binary under the cursor + `I` Install all missing binaries + `i` Install the binary under the cursor + `x` Abort all running tasks + `` Toggle detail expansion (URL, path, version, errors) + `q` Close the Glaze window + `` Close the Glaze window + +============================================================================== +5. API *glaze-api* *glaze.setup()* glaze.setup({opts}) @@ -78,14 +102,38 @@ glaze.binaries() *glaze.is_installed()* glaze.is_installed({name}) - Returns true if the binary is in PATH. + Returns true if the binary is found in PATH, $GOBIN, $GOPATH/bin, + or ~/go/bin. + + *glaze.bin_path()* +glaze.bin_path({name}) + Returns the full path to the binary, or nil if not found. *glaze.status()* glaze.status({name}) Returns "installed", "missing", or "unknown". + *glaze-runner-api* +Runner API (require("glaze.runner")): + + runner.update({names}) Update specific binaries + runner.update_all() Update all registered binaries + runner.install({names}) Install specific binaries + runner.install_missing() Install all missing binaries + runner.abort() Stop all running tasks + runner.is_running() Check if tasks are running + runner.tasks() Get current task list + runner.stats() Get task statistics + + *glaze-checker-api* +Checker API (require("glaze.checker")): + + checker.check() Check for updates (with notifications) + checker.auto_check() Check only if enough time has passed + checker.get_update_info() Get cached update info table + ============================================================================== -5. CONFIGURATION *glaze-config* +6. CONFIGURATION *glaze-config* Default configuration: >lua @@ -103,11 +151,15 @@ Default configuration: }, concurrency = 4, go_cmd = { "go" }, -- Auto-detects goenv + auto_check = { + enabled = true, -- Auto-check for updates on setup + frequency = "daily", -- "daily", "weekly", or hours (number) + }, }) < ============================================================================== -6. HIGHLIGHTS *glaze-highlights* +7. HIGHLIGHTS *glaze-highlights* All highlight groups are prefixed with "Glaze": @@ -121,9 +173,27 @@ All highlight groups are prefixed with "Glaze": GlazeRunning In-progress status GlazeProgressDone Progress bar (filled) GlazeProgressTodo Progress bar (empty) + GlazeVersion Version info + GlazeTime Timing info ============================================================================== -7. FOR PLUGIN AUTHORS *glaze-plugin-authors* +8. AUTO-UPDATE CHECKING *glaze-auto-check* + +Glaze can automatically check for newer versions of your registered binaries. + +When enabled, Glaze checks on setup if enough time has passed since the last +check. It uses `go list -m -json @latest` to find the latest version +and `go version -m ` to determine the installed version. + +State is stored in: `vim.fn.stdpath("data") .. "/glaze/state.json"` + +Use `:GlazeCheck` to manually trigger a check at any time. + +In the UI, binaries with available updates show a ⬆ indicator with version +info (e.g., "v1.0.0 → v1.1.0"). + +============================================================================== +9. FOR PLUGIN AUTHORS *glaze-plugin-authors* Register your binaries in your plugin's setup: >lua diff --git a/lua/glaze/checker.lua b/lua/glaze/checker.lua new file mode 100644 index 0000000..20390a5 --- /dev/null +++ b/lua/glaze/checker.lua @@ -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 +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 +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 diff --git a/lua/glaze/float.lua b/lua/glaze/float.lua index dbfd709..b85fa4d 100644 --- a/lua/glaze/float.lua +++ b/lua/glaze/float.lua @@ -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, }) diff --git a/lua/glaze/init.lua b/lua/glaze/init.lua index ae6738d..c45ed00 100644 --- a/lua/glaze/init.lua +++ b/lua/glaze/init.lua @@ -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 @@ -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. diff --git a/lua/glaze/runner.lua b/lua/glaze/runner.lua index b029125..7223cd9 100644 --- a/lua/glaze/runner.lua +++ b/lua/glaze/runner.lua @@ -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 diff --git a/lua/glaze/text.lua b/lua/glaze/text.lua index 589bbc9..6798a12 100644 --- a/lua/glaze/text.lua +++ b/lua/glaze/text.lua @@ -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 diff --git a/lua/glaze/view.lua b/lua/glaze/view.lua index 5519961..0c6a6f7 100644 --- a/lua/glaze/view.lua +++ b/lua/glaze/view.lua @@ -11,9 +11,26 @@ M._float = nil ---@type number? M._timer = nil +---@type table +M._expanded = {} + +---Line-to-binary mapping for cursor-aware actions. +---@type table +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 ---@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)