├── .gitignore ├── .luarc.json ├── LICENSE ├── README.md └── lua └── neonuget ├── ftplugin.lua ├── init.lua ├── nuget.lua ├── syntax.lua ├── ui.lua └── ui ├── available_package_list.lua ├── baseui.lua ├── details.lua ├── init.lua ├── installed_package_list.lua ├── search.lua ├── utils.lua └── version_list.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "diagnostics.globals": [ 3 | "vim" 4 | ], 5 | "diagnostics.disable": [ 6 | "undefined-global" 7 | ] 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 MonsieurTib 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NeoNuGet.nvim 2 | 3 | Manage your .NET project's NuGet packages without leaving Neovim! `neonuget.nvim` provides an interactive floating window UI to list installed packages (top-level and transitive), search for available packages on NuGet.org, view package details and versions, and install/uninstall packages using the `dotnet` CLI. 4 | 5 | ## Features 6 | 7 | - **List Packages**: View installed NuGet packages. 8 | - **Search Packages**: Search for available packages on NuGet.org. 9 | - **View Details**: Display metadata (description, author, license, etc.) for selected package versions. 10 | - **View Versions**: List all available versions for a package. 11 | - **Install/Uninstall**: Add or remove packages via the interactive UI (uses `dotnet` CLI). 12 | - **Interactive UI**: Uses floating windows for package lists, search, details, and versions. 13 | - **Multi project support**: Easily switch between .NET projects to manage their NuGet packages. 14 | 15 | ## Previews 16 | 17 | neonuget2 18 | 19 | ### Package uninstall 20 | 21 | neonuget3 22 | 23 | 24 | 25 | ## Installation 26 | 27 | Using [lazy.nvim](https://github.com/folke/lazy.nvim): 28 | 29 | ```lua 30 | { 31 | "MonsieurTib/neonuget", 32 | config = function() 33 | require("neonuget").setup({ 34 | -- Optional configuration 35 | dotnet_path = "dotnet", -- Path to dotnet CLI 36 | default_project = nil, -- Auto-detected, or specify path like "./MyProject/MyProject.csproj" 37 | }) 38 | end, 39 | dependencies = { 40 | "nvim-lua/plenary.nvim", 41 | } 42 | } 43 | ``` 44 | 45 | Using [packer.nvim](https://github.com/wbthomason/packer.nvim): 46 | 47 | ```lua 48 | use { 49 | "MonsieurTib/neonuget.nvim", 50 | requires = { "nvim-lua/plenary.nvim" }, 51 | config = function() 52 | require("neonuget").setup({ 53 | -- Optional configuration 54 | dotnet_path = "dotnet", -- Path to dotnet CLI 55 | default_project = nil, -- Auto-detected, or specify path like "./MyProject/MyProject.csproj" 56 | }) 57 | end 58 | } 59 | ``` 60 | 61 | ## Commands 62 | 63 | ### `:NuGet` 64 | 65 | Opens the main interactive UI. Lists installed packages and allows searching, installing, and uninstalling packages. 66 | 67 | ## Usage 68 | 69 | 1. Open a .NET project in Neovim. 70 | 2. Run `:NuGet` to open the main UI. 71 | 3. **Navigate Lists:** Use arrow keys (`j`/`k`) to move up/down in the package lists (Installed/Available/Versions). 72 | 4. **Select Package:** Press `` on a package (either installed or available) to view its versions and details in the right-hand panes. 73 | 5. **Switch Focus:** Use `` to cycle focus between the interactive panes (Search, Installed List, Available List, Versions List, Details Pane). 74 | 6. **Search:** Focus the search input (top-left) and type to filter installed packages and search available packages simultaneously. 75 | 7. **Install Package:** While the Versions list is focused, press `i` to install the currently selected version. 76 | 8. **Uninstall Package:** While the Installed Packages list is focused, press `dd` (or configure another key) to uninstall the selected top-level package. 77 | 9. **Close:** Press `q` or `` in any pane to close the UI. 78 | 79 | ## Requirements 80 | 81 | - Neovim 0.7+ (Requires `plenary.nvim`) 82 | - .NET SDK (with `dotnet` CLI accessible in your path) 83 | - A valid .NET project file (.csproj, .fsproj, or .vbproj) discoverable in the workspace (searches max 2 levels deep by default). 84 | - `plenary.nvim` plugin. 85 | 86 | ## Limitations 87 | 88 | - **Single Project Focus:** Currently, the plugin detects and operates on the first `.csproj`, `.fsproj`, or `.vbproj` file found in the workspace (searching up to 2 levels deep). Support for explicitly selecting projects in multi-project solutions is planned. 89 | - **Public NuGet Only:** Interaction is limited to the public NuGet.org repository. Support for private/custom NuGet feeds is not yet implemented. 90 | 91 | ## License 92 | 93 | MIT License 94 | -------------------------------------------------------------------------------- /lua/neonuget/ftplugin.lua: -------------------------------------------------------------------------------- 1 | local syntax = require("neonuget.syntax") 2 | 3 | local M = {} 4 | 5 | function M.setup() 6 | vim.api.nvim_create_augroup("neonuget_filetype", { clear = true }) 7 | 8 | vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, { 9 | group = "neonuget_filetype", 10 | pattern = { "NuGet_*" }, 11 | callback = function() 12 | vim.bo.filetype = "neonuget" 13 | end, 14 | }) 15 | 16 | vim.api.nvim_create_autocmd("FileType", { 17 | group = "neonuget_filetype", 18 | pattern = "neonuget", 19 | callback = function() 20 | syntax.setup() 21 | end, 22 | }) 23 | end 24 | 25 | return M 26 | -------------------------------------------------------------------------------- /lua/neonuget/init.lua: -------------------------------------------------------------------------------- 1 | local nuget = require("neonuget.nuget") 2 | local ui = require("neonuget.ui") 3 | local ftplugin = require("neonuget.ftplugin") 4 | 5 | local M = {} 6 | 7 | M._current_active_project = nil -- Stores the project path selected by the user for the current UI session 8 | 9 | M.config = { 10 | dotnet_path = "dotnet", 11 | default_project = nil, 12 | } 13 | 14 | function M.setup(opts) 15 | if opts then 16 | M.config = vim.tbl_deep_extend("force", M.config, opts) 17 | end 18 | 19 | ftplugin.setup() 20 | 21 | 22 | local group = vim.api.nvim_create_augroup("NeoNuGetHighlights", { clear = true }) 23 | vim.api.nvim_create_autocmd("ColorScheme", { 24 | group = group, 25 | pattern = "*", 26 | callback = function() 27 | vim.api.nvim_set_hl(0, "NuGetDetailsLabel", { fg = "#34B3FA", bold = true }) 28 | vim.api.nvim_set_hl(0, "NuGetUpdateAvailable", { fg = "#A6E3A1", bold = true }) 29 | end, 30 | }) 31 | 32 | 33 | pcall(vim.api.nvim_set_hl, 0, "NuGetDetailsLabel", { fg = "#34B3FA", bold = true }) 34 | pcall(vim.api.nvim_set_hl, 0, "NuGetUpdateAvailable", { fg = "#A6E3A1", bold = true }) 35 | 36 | vim.api.nvim_create_user_command("NuGet", function() 37 | M.list_packages() 38 | end, {}) 39 | end 40 | 41 | function M._get_active_project_or_notify() 42 | if M._current_active_project and M._current_active_project ~= "" then 43 | return M._current_active_project 44 | end 45 | 46 | local project_path = M.config.default_project or M._find_project() 47 | if not project_path then 48 | vim.notify("No active .NET project. Please run :NuGet first or set `default_project`.", vim.log.levels.ERROR) 49 | return nil 50 | end 51 | return project_path 52 | end 53 | 54 | function M.list_packages() 55 | local active_project_path = M.config.default_project 56 | 57 | if not active_project_path then 58 | local all_projects = M._find_all_projects() 59 | 60 | if #all_projects == 0 then 61 | vim.notify("No .NET project found in the workspace.", vim.log.levels.ERROR) 62 | M._current_active_project = nil 63 | return 64 | elseif #all_projects == 1 then 65 | active_project_path = all_projects[1] 66 | else 67 | -- Multiple projects, prompt user 68 | vim.ui.select(all_projects, { prompt = "Select a project:" }, function(choice) 69 | if not choice then 70 | vim.notify("Project selection cancelled.", vim.log.levels.INFO) 71 | M._current_active_project = nil 72 | return 73 | end 74 | M._current_active_project = choice 75 | M._list_packages_for_project(choice) 76 | end) 77 | return 78 | end 79 | end 80 | 81 | M._current_active_project = active_project_path 82 | M._list_packages_for_project(active_project_path) 83 | end 84 | 85 | function M._list_packages_for_project(project_path) 86 | -- Show UI immediately with loading states 87 | local windows = ui.display_dual_pane({}, { project_path = project_path }) 88 | if not windows then 89 | vim.notify("Failed to create package viewer interface", vim.log.levels.ERROR) 90 | return 91 | end 92 | 93 | -- Fetch packages in the background 94 | M.refresh_packages(function(packages) 95 | local packages_to_display = packages or {} 96 | if windows and windows.components and windows.components.package_list then 97 | windows.components.package_list.update_packages(packages_to_display) 98 | end 99 | end, project_path) 100 | end 101 | 102 | function M._find_all_projects() 103 | local projects = {} 104 | local patterns = { "*.csproj", "*.fsproj", "*.vbproj" } 105 | local search_paths = { ".", "./*", "./*/*" } 106 | 107 | for _, search_path_prefix in ipairs(search_paths) do 108 | for _, pattern in ipairs(patterns) do 109 | local glob_pattern = search_path_prefix .. "/" .. pattern 110 | local found_files = vim.fn.globpath(".", glob_pattern, true, true, true) 111 | for _, file_path in ipairs(found_files) do 112 | local normalized_path = vim.fn.fnamemodify(file_path, ":.") 113 | local already_added = false 114 | for _, existing_proj in ipairs(projects) do 115 | if existing_proj == normalized_path then 116 | already_added = true 117 | break 118 | end 119 | end 120 | if not already_added and normalized_path ~= "" then 121 | table.insert(projects, normalized_path) 122 | end 123 | end 124 | end 125 | end 126 | return projects 127 | end 128 | 129 | function M._find_project() 130 | local handle = io.popen("find . -maxdepth 2 -name '*.csproj' -o -name '*.fsproj' -o -name '*.vbproj'") 131 | if not handle then 132 | return nil 133 | end 134 | 135 | local result = handle:read("*a") 136 | handle:close() 137 | 138 | local project_file = result:match("(.-)\n") 139 | return project_file 140 | end 141 | 142 | function M._execute_command(command, callback) 143 | local stdout_data = {} 144 | local stderr_data = {} 145 | 146 | local job_id = vim.fn.jobstart(command, { 147 | on_stdout = function(_, data) 148 | if data and #data > 0 then 149 | if data[#data] == "" then 150 | table.remove(data, #data) 151 | end 152 | 153 | for _, line in ipairs(data) do 154 | table.insert(stdout_data, line) 155 | end 156 | end 157 | end, 158 | on_stderr = function(_, data) 159 | if data and #data > 0 then 160 | if data[#data] == "" then 161 | table.remove(data, #data) 162 | end 163 | 164 | for _, line in ipairs(data) do 165 | table.insert(stderr_data, line) 166 | end 167 | end 168 | end, 169 | on_exit = function(_, exitcode) 170 | if callback then 171 | callback(exitcode, stdout_data, stderr_data) 172 | end 173 | end, 174 | }) 175 | 176 | if job_id <= 0 then 177 | vim.notify("Failed to start command: " .. command, vim.log.levels.ERROR) 178 | if callback then 179 | callback(-1, {}, { "Failed to start job" }) 180 | end 181 | end 182 | end 183 | 184 | function M.install_package(pkg_name, version, callback) 185 | local project_path = M._get_active_project_or_notify() 186 | if not project_path then 187 | if callback then 188 | callback(false) 189 | end 190 | return 191 | end 192 | 193 | local command = M.config.dotnet_path .. " add \"" .. project_path .. "\" package " .. pkg_name 194 | if version then 195 | command = command .. " --version " .. version 196 | end 197 | 198 | M._execute_command(command, function(exitcode, _, stderr_data) 199 | if exitcode ~= 0 then 200 | local err_msg = table.concat(stderr_data, "\n") 201 | vim.notify( 202 | "Package installation failed: " .. (err_msg ~= "" and err_msg or "Exit code: " .. exitcode), 203 | vim.log.levels.ERROR 204 | ) 205 | if callback then 206 | callback(false) 207 | end 208 | else 209 | if callback then 210 | callback(true) 211 | end 212 | end 213 | end) 214 | end 215 | 216 | local function _restore_project(project_path, callback) 217 | local restore_command = M.config.dotnet_path .. " restore \"" .. project_path .. "\"" 218 | M._execute_command(restore_command, function(exitcode, _, stderr_data) 219 | if exitcode ~= 0 then 220 | local err_msg = table.concat(stderr_data, "\n") 221 | vim.notify( 222 | "Failed to restore project dependencies: " .. (err_msg ~= "" and err_msg or "Exit code: " .. exitcode), 223 | vim.log.levels.ERROR 224 | ) 225 | if callback then 226 | callback(false) 227 | end 228 | return 229 | end 230 | 231 | if callback then 232 | callback(true) 233 | end 234 | end) 235 | end 236 | 237 | function M.uninstall_package(pkg_name, callback) 238 | local project_path = M._get_active_project_or_notify() 239 | if not project_path then 240 | if callback then 241 | callback(false) 242 | end 243 | return false 244 | end 245 | 246 | local command = M.config.dotnet_path .. " remove \"" .. project_path .. "\" package " .. pkg_name 247 | 248 | M._execute_command(command, function(exitcode, _, stderr_data) 249 | if exitcode ~= 0 then 250 | local err_msg = table.concat(stderr_data, "\n") 251 | vim.notify( 252 | "Package uninstallation failed: " .. (err_msg ~= "" and err_msg or "Exit code: " .. exitcode), 253 | vim.log.levels.ERROR 254 | ) 255 | if callback then 256 | callback(false) 257 | end 258 | return 259 | end 260 | 261 | vim.defer_fn(function() 262 | _restore_project(project_path, function(restore_ok) 263 | if not restore_ok then 264 | if callback then 265 | callback(true, nil) 266 | end 267 | return 268 | end 269 | 270 | vim.defer_fn(function() 271 | M.refresh_packages(function(refreshed_packages) 272 | if callback then 273 | callback(true, refreshed_packages) 274 | end 275 | end) 276 | end, 500) 277 | end) 278 | end, 500) 279 | end) 280 | 281 | return true 282 | end 283 | 284 | function M.refresh_packages(callback, project_path_override) 285 | local project_path = project_path_override 286 | if not project_path then 287 | project_path = M._get_active_project_or_notify() 288 | if not project_path then 289 | if callback then 290 | callback(nil) 291 | end 292 | return 293 | end 294 | elseif project_path == "" then 295 | vim.notify("Provided project path for refresh is invalid.", vim.log.levels.ERROR) 296 | if callback then 297 | callback(nil) 298 | end 299 | return 300 | end 301 | 302 | -- Command 1: Get ALL installed packages 303 | local list_all_command = M.config.dotnet_path .. " list \"" .. project_path .. "\" package --include-transitive --format json" 304 | 305 | M._execute_command(list_all_command, function(exitcode_all, output_all, stderr_all) 306 | if exitcode_all ~= 0 then 307 | local err_msg_all = table.concat(stderr_all, "\n") 308 | vim.notify( 309 | "Failed to list all packages: " .. (err_msg_all ~= "" and err_msg_all or "Exit code: " .. exitcode_all), 310 | vim.log.levels.ERROR 311 | ) 312 | if callback then 313 | callback(nil) 314 | end 315 | return 316 | end 317 | 318 | if not output_all or #output_all == 0 then 319 | vim.notify("No output received from list all packages command.", vim.log.levels.ERROR) 320 | if callback then 321 | callback(nil) 322 | end 323 | return 324 | end 325 | 326 | local json_str_all = table.concat(output_all, "") 327 | local parse_all_ok, all_installed_packages_result = pcall(nuget.parse_json_package_list, json_str_all) 328 | 329 | if not parse_all_ok then 330 | vim.notify("Failed to parse list of all packages: " .. tostring(all_installed_packages_result), vim.log.levels.ERROR) 331 | if callback then 332 | callback(nil) 333 | end 334 | return 335 | end 336 | 337 | if not all_installed_packages_result then 338 | vim.notify("Parsed list of all packages is unexpectedly nil.", vim.log.levels.ERROR) 339 | if callback then 340 | callback(nil) 341 | end 342 | return 343 | end 344 | 345 | -- Command 2: Get OUTDATED packages (for accurate latestVersion) 346 | local list_outdated_command = M.config.dotnet_path .. " list \"" .. project_path .. "\" package --outdated --format json" -- No --include-transitive needed here, we only care about top-level latest versions. 347 | M._execute_command(list_outdated_command, function(exitcode_outdated, output_outdated, stderr_outdated) 348 | local outdated_packages_info = {} -- Store as a lookup table: { ["Package.Name"] = "LatestVersion", ... } 349 | 350 | if exitcode_outdated == 0 and output_outdated and #output_outdated > 0 then 351 | local json_str_outdated = table.concat(output_outdated, "") 352 | local parse_outdated_ok, parsed_outdated_list = pcall(nuget.parse_json_package_list, json_str_outdated) 353 | 354 | if parse_outdated_ok and parsed_outdated_list then 355 | for _, pkg_outdated in ipairs(parsed_outdated_list) do 356 | if pkg_outdated.name and pkg_outdated.latest_version and pkg_outdated.latest_version ~= "" then 357 | outdated_packages_info[pkg_outdated.name] = pkg_outdated.latest_version 358 | end 359 | end 360 | else 361 | if not parse_outdated_ok then 362 | vim.notify("Failed to parse outdated package list: " .. tostring(parsed_outdated_list) .. ". Update indicators may be inaccurate.", vim.log.levels.WARN) 363 | else 364 | vim.notify("Problem parsing outdated package list or no outdated packages found. Update indicators may be inaccurate.", vim.log.levels.INFO) 365 | end 366 | end 367 | else 368 | local err_msg_outdated = table.concat(stderr_outdated or {}, "\n") 369 | vim.notify( 370 | "Failed to fetch outdated package details (" .. (err_msg_outdated ~= "" and err_msg_outdated or "Exit code: " .. exitcode_outdated) .. "). Update indicators may be inaccurate.", 371 | vim.log.levels.WARN 372 | ) 373 | end 374 | 375 | for _, pkg_installed in ipairs(all_installed_packages_result) do 376 | if outdated_packages_info[pkg_installed.name] then 377 | pkg_installed.latest_version = outdated_packages_info[pkg_installed.name] 378 | else 379 | pkg_installed.latest_version = pkg_installed.resolved_version 380 | end 381 | end 382 | 383 | if callback then 384 | callback(all_installed_packages_result) 385 | end 386 | end) 387 | end) 388 | return true 389 | end 390 | 391 | return M 392 | -------------------------------------------------------------------------------- /lua/neonuget/nuget.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local curl 4 | local curl_ok, _ = pcall(require, "plenary.curl") 5 | if curl_ok then 6 | curl = require("plenary.curl") 7 | else 8 | vim.notify("plenary.curl is not available. Please install plenary.nvim.", vim.log.levels.ERROR) 9 | end 10 | 11 | local NUGET_API_BASE_URL = "https://api.nuget.org/v3-flatcontainer/" 12 | local NUGET_REGISTRATION_BASE_URL = "https://api.nuget.org/v3/registration5-gz-semver2/" 13 | local NUGET_SEARCH_BASE_URL = "https://azuresearch-usnc.nuget.org/query" 14 | 15 | local function make_request(url, callback, error_message, use_compression) 16 | if not curl then 17 | callback(nil, "curl is not available") 18 | return 19 | end 20 | 21 | error_message = error_message or "Failed to fetch data from NuGet API" 22 | use_compression = use_compression or false 23 | 24 | local request_options = { 25 | headers = { 26 | ["Accept"] = "application/json", 27 | }, 28 | callback = vim.schedule_wrap(function(response) 29 | if not response or response.exit ~= 0 or not response.body or response.body == "" then 30 | local details = "" 31 | if response then 32 | details = "Exit code: " .. tostring(response.exit) 33 | if response.stderr and response.stderr ~= "" then 34 | details = details .. " - " .. response.stderr 35 | end 36 | end 37 | 38 | print("[ERROR] " .. error_message .. " - Details: " .. details) 39 | callback(nil, error_message) 40 | return 41 | end 42 | callback(response.body, nil) 43 | end), 44 | } 45 | 46 | if use_compression then 47 | request_options.headers["Accept-Encoding"] = "gzip, deflate" 48 | request_options.compressed = true 49 | end 50 | 51 | curl.get(url, request_options) 52 | end 53 | 54 | function M.fetch_package_versions(package_id, callback) 55 | if not package_id or package_id == "" then 56 | vim.notify("Invalid package ID provided", vim.log.levels.ERROR) 57 | callback(nil) 58 | return 59 | end 60 | 61 | local url = NUGET_REGISTRATION_BASE_URL .. string.lower(package_id) .. "/index.json" 62 | make_request(url, function(body, err) 63 | if err then 64 | vim.notify("Could not fetch package information: " .. err, vim.log.levels.ERROR) 65 | callback(nil) 66 | return 67 | end 68 | 69 | local ok, parsed_info = pcall(vim.fn.json_decode, body) 70 | 71 | if not ok or not parsed_info then 72 | vim.notify("Failed to parse package info JSON", vim.log.levels.ERROR) 73 | callback(nil) 74 | return 75 | end 76 | 77 | if parsed_info and parsed_info.items and #parsed_info.items > 0 then 78 | local versions = {} 79 | local version_pages = parsed_info.items 80 | 81 | local has_nested_items = false 82 | for _, page in ipairs(version_pages) do 83 | if page.items and #page.items > 0 then 84 | has_nested_items = true 85 | for _, item in ipairs(page.items) do 86 | if item.catalogEntry and item.catalogEntry.version then 87 | table.insert(versions, item.catalogEntry.version) 88 | end 89 | end 90 | else 91 | table.insert(versions, { 92 | lower = page.lower, 93 | upper = page.upper, 94 | page_url = page["@id"], 95 | }) 96 | end 97 | end 98 | 99 | if has_nested_items then 100 | callback(versions) 101 | else 102 | M.fetch_version_pages(versions, callback) 103 | end 104 | else 105 | local fallback_url = NUGET_API_BASE_URL .. string.lower(package_id) .. "/index.json" 106 | make_request(fallback_url, function(fallback_body, fallback_err) 107 | if fallback_err then 108 | vim.notify("Could not fetch package information using fallback", vim.log.levels.ERROR) 109 | callback(nil) 110 | return 111 | end 112 | 113 | local fallback_ok, fallback_data = pcall(M.parse_package_info, fallback_body) 114 | if not fallback_ok or not fallback_data then 115 | vim.notify("Failed to parse package info with fallback method", vim.log.levels.ERROR) 116 | callback(nil) 117 | return 118 | end 119 | 120 | callback(fallback_data) 121 | end, false) 122 | end 123 | end, "Failed to fetch package versions for " .. package_id, true) 124 | end 125 | 126 | function M.fetch_all_package_versions(package_id, callback) 127 | if not package_id or package_id == "" then 128 | vim.notify("Invalid package ID provided", vim.log.levels.ERROR) 129 | callback(nil) 130 | return 131 | end 132 | 133 | local url = NUGET_REGISTRATION_BASE_URL .. string.lower(package_id) .. "/index.json" 134 | make_request(url, function(body, err) 135 | if err then 136 | vim.notify("Could not fetch package information: " .. err, vim.log.levels.ERROR) 137 | callback(nil) 138 | return 139 | end 140 | 141 | local ok, parsed_info = pcall(vim.fn.json_decode, body) 142 | 143 | if not ok or not parsed_info then 144 | vim.notify("Failed to parse package info JSON", vim.log.levels.ERROR) 145 | callback(nil) 146 | return 147 | end 148 | 149 | if parsed_info and parsed_info.items and #parsed_info.items > 0 then 150 | local version_infos = {} 151 | local version_pages = parsed_info.items 152 | 153 | local has_nested_items = false 154 | for _, page in ipairs(version_pages) do 155 | if page.items and #page.items > 0 then 156 | has_nested_items = true 157 | for _, item in ipairs(page.items) do 158 | if item.catalogEntry then 159 | local catalog_entry = item.catalogEntry 160 | local version_info = { 161 | version = catalog_entry.version or "", 162 | description = catalog_entry.description or "", 163 | authors = catalog_entry.authors or "", 164 | published = catalog_entry.published or "", 165 | totalDownloads = catalog_entry.totalDownloads or 0, 166 | tags = M.process_tags(catalog_entry.tags), 167 | projectUrl = catalog_entry.projectUrl or "", 168 | licenseUrl = catalog_entry.licenseUrl or "", 169 | dependencies = catalog_entry.dependencyGroups or {}, 170 | } 171 | table.insert(version_infos, version_info) 172 | end 173 | end 174 | end 175 | end 176 | 177 | if has_nested_items then 178 | callback(version_infos) 179 | else 180 | local pages_to_fetch = {} 181 | for _, page in ipairs(version_pages) do 182 | if page["@id"] then 183 | table.insert(pages_to_fetch, { 184 | url = page["@id"], 185 | lower = page.lower, 186 | upper = page.upper, 187 | }) 188 | end 189 | end 190 | 191 | if #pages_to_fetch > 0 then 192 | M.fetch_version_pages_with_metadata(pages_to_fetch, callback) 193 | else 194 | M.fallback_fetch_versions(package_id, callback) 195 | end 196 | end 197 | else 198 | M.fallback_fetch_versions(package_id, callback) 199 | end 200 | end, "Failed to fetch package versions for " .. package_id, true) 201 | end 202 | 203 | function M.fallback_fetch_versions(package_id, callback) 204 | local fallback_url = NUGET_API_BASE_URL .. string.lower(package_id) .. "/index.json" 205 | make_request(fallback_url, function(fallback_body, fallback_err) 206 | if fallback_err then 207 | vim.notify("Could not fetch package information using fallback", vim.log.levels.ERROR) 208 | callback(nil) 209 | return 210 | end 211 | 212 | local fallback_ok, versions = pcall(M.parse_package_info, fallback_body) 213 | if not fallback_ok or not versions then 214 | vim.notify("Failed to parse package info with fallback method", vim.log.levels.ERROR) 215 | callback(nil) 216 | return 217 | end 218 | 219 | local version_infos = {} 220 | for _, version_str in ipairs(versions) do 221 | table.insert(version_infos, { 222 | version = version_str, 223 | description = "No description available", 224 | authors = "Unknown", 225 | published = "", 226 | totalDownloads = 0, 227 | tags = {}, 228 | projectUrl = "", 229 | licenseUrl = "", 230 | }) 231 | end 232 | 233 | callback(version_infos) 234 | end, false) 235 | end 236 | 237 | function M.fetch_version_pages_with_metadata(version_pages, callback) 238 | if not version_pages or #version_pages == 0 then 239 | callback({}) 240 | return 241 | end 242 | 243 | local version_infos = {} 244 | local pending_requests = #version_pages 245 | 246 | for _, page in ipairs(version_pages) do 247 | make_request(page.url, function(body, err) 248 | pending_requests = pending_requests - 1 249 | 250 | if not err and body then 251 | local ok, page_data = pcall(vim.fn.json_decode, body) 252 | if ok and page_data then 253 | if page_data.items and #page_data.items > 0 then 254 | for _, item in ipairs(page_data.items) do 255 | if item.catalogEntry then 256 | local catalog_entry = item.catalogEntry 257 | local version_info = { 258 | version = catalog_entry.version or "", 259 | description = catalog_entry.description or "", 260 | authors = catalog_entry.authors or "", 261 | published = catalog_entry.published or "", 262 | totalDownloads = catalog_entry.totalDownloads or 0, 263 | tags = M.process_tags(catalog_entry.tags), 264 | projectUrl = catalog_entry.projectUrl or "", 265 | licenseUrl = catalog_entry.licenseUrl or "", 266 | dependencies = catalog_entry.dependencyGroups or {}, 267 | } 268 | table.insert(version_infos, version_info) 269 | elseif item.version or item["@id"] then 270 | local version_info = { 271 | version = item.version or "", 272 | description = item.description or "", 273 | authors = item.authors or "", 274 | published = item.published or "", 275 | totalDownloads = item.totalDownloads or 0, 276 | tags = M.process_tags(item.tags), 277 | projectUrl = item.projectUrl or "", 278 | licenseUrl = item.licenseUrl or "", 279 | } 280 | table.insert(version_infos, version_info) 281 | end 282 | end 283 | elseif page_data.version then 284 | local version_info = { 285 | version = page_data.version or "", 286 | description = page_data.description or "", 287 | authors = page_data.authors or "", 288 | published = page_data.published or "", 289 | totalDownloads = page_data.totalDownloads or 0, 290 | tags = M.process_tags(page_data.tags), 291 | projectUrl = page_data.projectUrl or "", 292 | licenseUrl = page_data.licenseUrl or "", 293 | } 294 | table.insert(version_infos, version_info) 295 | end 296 | end 297 | end 298 | 299 | if pending_requests == 0 then 300 | table.sort(version_infos, function(a, b) 301 | return a.version > b.version 302 | end) 303 | 304 | local unique_versions = {} 305 | local seen = {} 306 | for _, v in ipairs(version_infos) do 307 | if v.version and v.version ~= "" and not seen[v.version] then 308 | seen[v.version] = true 309 | table.insert(unique_versions, v) 310 | end 311 | end 312 | 313 | callback(unique_versions) 314 | end 315 | end, "Failed to fetch version page: " .. page.url, true) 316 | end 317 | end 318 | 319 | function M.parse_package_info(json_str) 320 | if not json_str or json_str == "" then 321 | return nil 322 | end 323 | 324 | local ok, json_data = pcall(vim.fn.json_decode, json_str) 325 | 326 | if not ok or not json_data or not json_data.versions then 327 | return nil 328 | end 329 | 330 | return json_data.versions 331 | end 332 | 333 | function M.fetch_package_metadata(package_id, version, callback) 334 | if not package_id or package_id == "" then 335 | vim.notify("Invalid package ID provided for metadata fetch", vim.log.levels.ERROR) 336 | callback(nil) 337 | return 338 | end 339 | if not version or version == "" then 340 | vim.notify("Invalid version provided for metadata fetch", vim.log.levels.ERROR) 341 | callback(nil) 342 | return 343 | end 344 | 345 | local base_url = NUGET_REGISTRATION_BASE_URL 346 | local url = base_url .. string.lower(package_id) .. "/" .. version .. ".json" 347 | 348 | make_request(url, function(body, err) 349 | if err then 350 | vim.notify("Could not fetch package metadata", vim.log.levels.ERROR) 351 | callback(nil) 352 | return 353 | end 354 | 355 | local ok, result = pcall(M.parse_package_metadata, body) 356 | 357 | if not ok then 358 | vim.notify("Error during metadata parsing", vim.log.levels.ERROR) 359 | callback(nil) 360 | return 361 | end 362 | 363 | if type(result) == "string" then 364 | vim.notify("Failed to parse package metadata", vim.log.levels.ERROR) 365 | callback(nil) 366 | return 367 | end 368 | 369 | if result and result._needs_catalog_fetch and result.url then 370 | if result.id then 371 | package_id = result.id 372 | end 373 | 374 | M.fetch_catalog_metadata(result.url, callback) 375 | return 376 | end 377 | 378 | callback(result) 379 | end, "Failed to fetch metadata for " .. package_id .. " version " .. version, true) 380 | end 381 | 382 | function M.process_tags(tags) 383 | if not tags then 384 | return {} 385 | end 386 | 387 | if type(tags) == "table" then 388 | return tags 389 | end 390 | 391 | if type(tags) == "string" and tags ~= "" then 392 | local result = {} 393 | for tag in string.gmatch(tags, "([^,%s;]+)") do 394 | table.insert(result, tag) 395 | end 396 | return result 397 | end 398 | 399 | return {} 400 | end 401 | 402 | function M.fetch_catalog_metadata(catalog_url, callback) 403 | if not catalog_url or catalog_url == "" then 404 | vim.notify("Invalid catalog URL provided", vim.log.levels.ERROR) 405 | callback(nil) 406 | return 407 | end 408 | make_request(catalog_url, function(body, err) 409 | if err then 410 | vim.notify("Could not fetch catalog metadata", vim.log.levels.ERROR) 411 | callback(nil) 412 | return 413 | end 414 | 415 | local ok, json_data = pcall(vim.fn.json_decode, body) 416 | 417 | if not ok then 418 | vim.notify("Failed to parse catalog JSON", vim.log.levels.ERROR) 419 | callback(nil) 420 | return 421 | end 422 | 423 | local catalog_data = json_data 424 | 425 | if json_data.catalogEntry then 426 | if type(json_data.catalogEntry) == "table" then 427 | catalog_data = json_data.catalogEntry 428 | end 429 | end 430 | 431 | local metadata = { 432 | description = catalog_data.description or "", 433 | authors = catalog_data.authors or "", 434 | published = catalog_data.published or "", 435 | totalDownloads = catalog_data.totalDownloads or json_data.totalDownloads or 0, 436 | tags = M.process_tags(catalog_data.tags), 437 | projectUrl = catalog_data.projectUrl or "", 438 | licenseUrl = catalog_data.licenseUrl or "", 439 | } 440 | 441 | callback(metadata) 442 | end, "Failed to fetch catalog metadata from " .. catalog_url, true) 443 | end 444 | 445 | function M.parse_package_metadata(json_str) 446 | if not json_str or json_str == "" then 447 | return "Empty JSON string received" 448 | end 449 | 450 | local ok, json_data = pcall(vim.fn.json_decode, json_str) 451 | 452 | if not ok then 453 | return "JSON decode failed: " .. tostring(json_data) 454 | end 455 | 456 | local catalog_entry 457 | local catalog_url 458 | local package_id = nil 459 | local download_count = nil 460 | 461 | if json_data.id then 462 | package_id = json_data.id 463 | end 464 | 465 | if json_data.totalDownloads then 466 | download_count = json_data.totalDownloads 467 | end 468 | 469 | if json_data.catalogEntry then 470 | if type(json_data.catalogEntry) == "string" then 471 | catalog_url = json_data.catalogEntry 472 | else 473 | catalog_entry = json_data.catalogEntry 474 | if catalog_entry.id and not package_id then 475 | package_id = catalog_entry.id 476 | end 477 | 478 | if catalog_entry.totalDownloads and not download_count then 479 | download_count = catalog_entry.totalDownloads 480 | end 481 | end 482 | elseif json_data.items and #json_data.items > 0 then 483 | local latest_page = json_data.items[#json_data.items] 484 | 485 | if latest_page and latest_page.items and #latest_page.items > 0 then 486 | local latest_item = latest_page.items[#latest_page.items] 487 | 488 | if latest_item.id and not package_id then 489 | package_id = latest_item.id 490 | end 491 | 492 | if latest_item.totalDownloads and not download_count then 493 | download_count = latest_item.totalDownloads 494 | end 495 | 496 | if latest_item and latest_item.catalogEntry then 497 | if type(latest_item.catalogEntry) == "string" then 498 | catalog_url = latest_item.catalogEntry 499 | else 500 | catalog_entry = latest_item.catalogEntry 501 | if catalog_entry.id and not package_id then 502 | package_id = catalog_entry.id 503 | end 504 | 505 | if catalog_entry.totalDownloads and not download_count then 506 | download_count = catalog_entry.totalDownloads 507 | end 508 | end 509 | end 510 | end 511 | end 512 | 513 | if catalog_url then 514 | return { _needs_catalog_fetch = true, url = catalog_url, id = package_id, totalDownloads = download_count } 515 | end 516 | 517 | if not catalog_entry then 518 | if json_data.data and #json_data.data > 0 then 519 | local latest_item = json_data.data[1] 520 | catalog_entry = latest_item 521 | if latest_item.id and not package_id then 522 | package_id = latest_item.id 523 | end 524 | 525 | if latest_item.totalDownloads and not download_count then 526 | download_count = latest_item.totalDownloads 527 | end 528 | elseif json_data.description then 529 | catalog_entry = json_data 530 | if json_data.totalDownloads and not download_count then 531 | download_count = json_data.totalDownloads 532 | end 533 | else 534 | return "Could not find catalog entry in JSON structure: " .. vim.inspect(vim.tbl_keys(json_data)) 535 | end 536 | end 537 | 538 | local metadata = { 539 | description = catalog_entry.description or "", 540 | authors = catalog_entry.authors or "", 541 | published = catalog_entry.published or "", 542 | totalDownloads = download_count or catalog_entry.totalDownloads or 0, 543 | tags = M.process_tags(catalog_entry.tags), 544 | projectUrl = catalog_entry.projectUrl or "", 545 | licenseUrl = catalog_entry.licenseUrl or "", 546 | } 547 | 548 | return metadata 549 | end 550 | 551 | function M.parse_json_package_list(json_str) 552 | local packages = {} 553 | 554 | if not json_str or json_str == "" then 555 | vim.notify("Empty JSON data received", vim.log.levels.ERROR) 556 | return packages 557 | end 558 | 559 | local ok, json_data 560 | ok, json_data = pcall(vim.fn.json_decode, json_str) 561 | 562 | if not ok or not json_data then 563 | vim.notify("Failed to parse JSON: " .. (json_data or "unknown error"), vim.log.levels.ERROR) 564 | return packages 565 | end 566 | 567 | if not json_data.projects then 568 | vim.notify("No projects found in JSON data", vim.log.levels.WARN) 569 | return packages 570 | end 571 | 572 | for _, project in ipairs(json_data.projects) do 573 | if project.frameworks then 574 | for _, framework in ipairs(project.frameworks) do 575 | if framework.topLevelPackages then 576 | for _, pkg in ipairs(framework.topLevelPackages) do 577 | table.insert(packages, { 578 | section = "Top-level", 579 | name = pkg.id, 580 | is_top_level = true, 581 | requested_version = pkg.requestedVersion or "", 582 | resolved_version = pkg.resolvedVersion or "", 583 | latest_version = pkg.latestVersion or "", 584 | }) 585 | end 586 | end 587 | 588 | if framework.transitivePackages then 589 | for _, pkg in ipairs(framework.transitivePackages) do 590 | table.insert(packages, { 591 | section = "Transitive", 592 | name = pkg.id, 593 | is_top_level = false, 594 | requested_version = pkg.requestedVersion or "", 595 | resolved_version = pkg.resolvedVersion or "", 596 | latest_version = pkg.latestVersion or "", 597 | }) 598 | end 599 | end 600 | end 601 | end 602 | end 603 | 604 | return packages 605 | end 606 | 607 | function M.fetch_available_packages(params, callback) 608 | params = params or {} 609 | local q = params.q or "" 610 | local prerelease = params.prerelease or false 611 | local semVerLevel = params.semVerLevel or "2.0.0" 612 | local take = params.take or 10 613 | local skip = params.skip or 0 614 | local sortBy = params.sortBy or "relevance" 615 | 616 | local base_url = NUGET_SEARCH_BASE_URL 617 | local query_string = "?q=" 618 | .. vim.uri_encode(q) 619 | .. "&prerelease=" 620 | .. tostring(prerelease) 621 | .. "&semVerLevel=" 622 | .. semVerLevel 623 | .. "&take=" 624 | .. tostring(take) 625 | .. "&skip=" 626 | .. tostring(skip) 627 | .. "&sortBy=" 628 | .. sortBy 629 | 630 | local full_url = base_url .. query_string 631 | 632 | make_request(full_url, function(body, err) 633 | if err then 634 | vim.notify("Could not fetch available packages", vim.log.levels.ERROR) 635 | callback(nil) 636 | return 637 | end 638 | 639 | local ok, parsed_data = pcall(M.parse_available_packages, body) 640 | 641 | if not ok or not parsed_data then 642 | vim.notify("Failed to parse available packages", vim.log.levels.ERROR) 643 | callback(nil) 644 | return 645 | end 646 | 647 | callback(parsed_data) 648 | end, "Failed to fetch available packages with query '" .. q .. "'", false) 649 | end 650 | 651 | function M.parse_available_packages(json_str) 652 | if not json_str or json_str == "" then 653 | return nil 654 | end 655 | 656 | local ok, json_data = pcall(vim.fn.json_decode, json_str) 657 | 658 | if not ok or not json_data then 659 | return nil 660 | end 661 | 662 | local packages = {} 663 | local total_count = json_data.totalHits or 0 664 | 665 | if json_data.data and #json_data.data > 0 then 666 | for _, pkg in ipairs(json_data.data) do 667 | table.insert(packages, { 668 | section = "Available", 669 | name = pkg.id, 670 | is_top_level = false, 671 | resolved_version = pkg.version or "", 672 | latest_version = pkg.version or "", 673 | description = pkg.description or "", 674 | authors = pkg.authors or "", 675 | total_downloads = pkg.totalDownloads or 0, 676 | icon_url = pkg.iconUrl or "", 677 | }) 678 | end 679 | end 680 | 681 | return { 682 | packages = packages, 683 | total_count = total_count, 684 | } 685 | end 686 | 687 | return M 688 | -------------------------------------------------------------------------------- /lua/neonuget/syntax.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.setup() 4 | vim.cmd([[ 5 | syntax clear 6 | 7 | syntax match NeonugetHeader /^#.*/ 8 | syntax match NeonugetSubHeader /^##.*/ 9 | syntax match NeonugetBold /\*\*.\{-}\*\*/ 10 | 11 | " Package information 12 | syntax match NeonugetPackageName /^# Package: .\+$/ 13 | syntax match NeonugetVersion /\d\+\.\d\+\.\d\+\(-[a-zA-Z0-9.]\+\)\?/ 14 | 15 | " Status indicators 16 | syntax match NeonugetOutdated /This package has an update available!/ 17 | syntax keyword NeonugetSection Top-level Transitive 18 | 19 | " Code blocks 20 | syntax region NeonugetCodeBlock start=/```/ end=/```/ 21 | 22 | " Command Examples 23 | syntax match NeonugetCommand /dotnet add package.\+/ 24 | 25 | " Set highlight groups 26 | highlight default link NeonugetHeader Title 27 | highlight default link NeonugetSubHeader Statement 28 | highlight default link NeonugetBold Special 29 | highlight default link NeonugetPackageName Title 30 | highlight default link NeonugetVersion Number 31 | highlight default link NeonugetOutdated WarningMsg 32 | highlight default link NeonugetSection Type 33 | highlight default link NeonugetCodeBlock Comment 34 | highlight default link NeonugetCommand String 35 | ]]) 36 | end 37 | 38 | return M 39 | -------------------------------------------------------------------------------- /lua/neonuget/ui.lua: -------------------------------------------------------------------------------- 1 | local ui_module = require("neonuget.ui.init") 2 | return ui_module 3 | -------------------------------------------------------------------------------- /lua/neonuget/ui/available_package_list.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local utils = require("neonuget.ui.utils") 4 | local nuget = require("neonuget.nuget") 5 | local baseui = require("neonuget.ui.baseui") 6 | 7 | function M.create(opts) 8 | opts = opts or {} 9 | local width = opts.width or 30 10 | local height = opts.height or 20 11 | local col = opts.col or 0 12 | local row = opts.row or 0 13 | local on_select = opts.on_select or function() end 14 | local params = opts.params or {} 15 | 16 | local available_packages = {} 17 | local package_lookup = {} 18 | local package_indices = {} 19 | 20 | local update_list_display 21 | 22 | local function fetch_packages(comp) 23 | if not comp or not comp.buf or not vim.api.nvim_buf_is_valid(comp.buf) then 24 | vim.notify("fetch_packages: Invalid component", vim.log.levels.ERROR) 25 | return 26 | end 27 | 28 | vim.api.nvim_buf_set_option(comp.buf, "modifiable", true) 29 | vim.api.nvim_buf_set_lines(comp.buf, 0, -1, false, { "Loading available packages..." }) 30 | vim.api.nvim_buf_set_option(comp.buf, "modifiable", false) 31 | 32 | nuget.fetch_available_packages(params, function(result) 33 | if not comp or not comp.buf or not vim.api.nvim_buf_is_valid(comp.buf) then 34 | vim.notify("fetch_packages callback: Component is no longer valid", vim.log.levels.ERROR) 35 | return 36 | end 37 | 38 | if not result or not result.packages then 39 | vim.api.nvim_buf_set_option(comp.buf, "modifiable", true) 40 | vim.api.nvim_buf_set_lines(comp.buf, 0, -1, false, { "No packages found" }) 41 | vim.api.nvim_buf_set_option(comp.buf, "modifiable", false) 42 | return 43 | end 44 | 45 | update_list_display(comp, result.packages) 46 | end) 47 | end 48 | 49 | update_list_display = function(comp, pkgs) 50 | if not comp or not comp.buf or not vim.api.nvim_buf_is_valid(comp.buf) then 51 | vim.notify("update_list_display: Invalid component or buffer", vim.log.levels.ERROR) 52 | return 53 | end 54 | 55 | available_packages = pkgs or {} 56 | 57 | local list_content = {} 58 | package_lookup = {} 59 | package_indices = {} 60 | 61 | for i, pkg in ipairs(available_packages) do 62 | local line = pkg.name .. " (" .. pkg.latest_version .. ")" 63 | 64 | table.insert(list_content, line) 65 | package_lookup[i] = pkg 66 | package_indices[#list_content] = i 67 | end 68 | 69 | if #available_packages == 0 then 70 | list_content = { "No packages found" } 71 | end 72 | 73 | vim.api.nvim_buf_set_option(comp.buf, "modifiable", true) 74 | vim.api.nvim_buf_set_lines(comp.buf, 0, -1, false, list_content) 75 | vim.api.nvim_buf_set_option(comp.buf, "modifiable", false) 76 | end 77 | 78 | local function handle_enter(buf, win) 79 | if not win or not vim.api.nvim_win_is_valid(win) then 80 | return 81 | end 82 | 83 | local cursor = vim.api.nvim_win_get_cursor(win) 84 | local line_num = cursor[1] 85 | 86 | if line_num <= 0 or line_num > vim.api.nvim_buf_line_count(buf) then 87 | return 88 | end 89 | 90 | if 91 | package_indices 92 | and package_indices[line_num] 93 | and package_lookup 94 | and package_lookup[package_indices[line_num]] 95 | then 96 | on_select(package_lookup[package_indices[line_num]]) 97 | end 98 | end 99 | 100 | local function handle_search() 101 | if opts.on_search then 102 | opts.on_search() 103 | end 104 | end 105 | 106 | local function handle_tab() 107 | if opts.on_tab then 108 | opts.on_tab() 109 | end 110 | end 111 | 112 | local function handle_close() 113 | if opts.on_close then 114 | opts.on_close() 115 | end 116 | end 117 | 118 | local component = baseui.create_component({ 119 | title = "Available Packages", 120 | width = width, 121 | height = height, 122 | col = col, 123 | row = row, 124 | buffer_name = "NuGet_Available_Package_List", 125 | initial_content = { "Loading available packages..." }, 126 | cursorline = true, 127 | mappings = {}, 128 | }) 129 | 130 | if not component then 131 | return nil 132 | end 133 | 134 | utils.set_buffer_mappings(component.buf, { 135 | n = { 136 | [""] = function() 137 | handle_enter(component.buf, component.win) 138 | end, 139 | ["r"] = function() 140 | fetch_packages(component) 141 | end, 142 | ["/"] = handle_search, 143 | [""] = handle_tab, 144 | ["q"] = handle_close, 145 | [""] = handle_close, 146 | }, 147 | }) 148 | 149 | fetch_packages(component) 150 | 151 | component.update_params = function(new_params) 152 | for k, v in pairs(new_params) do 153 | params[k] = v 154 | end 155 | 156 | fetch_packages(component) 157 | end 158 | 159 | component.get_selected_package = function() 160 | if not component.win or not vim.api.nvim_win_is_valid(component.win) then 161 | return nil 162 | end 163 | 164 | local cursor = vim.api.nvim_win_get_cursor(component.win) 165 | local line_num = cursor[1] 166 | 167 | if line_num <= 0 or line_num > vim.api.nvim_buf_line_count(component.buf) then 168 | return nil 169 | end 170 | 171 | if 172 | package_indices 173 | and package_indices[line_num] 174 | and package_lookup 175 | and package_lookup[package_indices[line_num]] 176 | then 177 | return package_lookup[package_indices[line_num]] 178 | end 179 | 180 | return nil 181 | end 182 | 183 | return component 184 | end 185 | 186 | return M 187 | -------------------------------------------------------------------------------- /lua/neonuget/ui/baseui.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local utils = require("neonuget.ui.utils") 4 | 5 | function M.create_component(opts) 6 | local component = {} 7 | opts = opts or {} 8 | local title = opts.title or "NuGet" 9 | local width = opts.width or 30 10 | local height = opts.height or 20 11 | local col = opts.col or 0 12 | local row = opts.row or 0 13 | local buffer_name = opts.buffer_name or "NuGet_Component" 14 | local zindex = opts.zindex or 50 15 | 16 | local buf = utils.create_buffer(buffer_name) 17 | if not buf then 18 | vim.notify("Failed to create buffer: " .. buffer_name, vim.log.levels.ERROR) 19 | return nil 20 | end 21 | if opts.initial_content then 22 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 23 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, opts.initial_content) 24 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 25 | end 26 | local win = nil 27 | local win_ok, win_err = pcall(function() 28 | win = vim.api.nvim_open_win(buf, opts.focus or false, { 29 | relative = "editor", 30 | width = width, 31 | height = height, 32 | col = col, 33 | row = row, 34 | border = "rounded", 35 | title = " " .. title .. " ", 36 | title_pos = "center", 37 | style = "minimal", 38 | zindex = zindex, 39 | }) 40 | end) 41 | if not win_ok then 42 | vim.notify("Error creating window: " .. tostring(win_err), vim.log.levels.ERROR) 43 | return nil 44 | end 45 | utils.configure_window(win, { 46 | number = false, 47 | relativenumber = false, 48 | signcolumn = "no", 49 | foldcolumn = "0", 50 | scrolloff = 5, 51 | sidescrolloff = 5, 52 | wrap = opts.wrap or false, 53 | cursorline = opts.cursorline or true, 54 | }) 55 | vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") 56 | if opts.filetype then 57 | vim.api.nvim_buf_set_option(buf, "filetype", opts.filetype) 58 | end 59 | component.buf = buf 60 | component.win = win 61 | component.title = title 62 | 63 | component.focus = function() 64 | if win and vim.api.nvim_win_is_valid(win) then 65 | vim.api.nvim_set_current_win(win) 66 | utils.configure_window(win, { 67 | number = false, 68 | relativenumber = false, 69 | signcolumn = "no", 70 | foldcolumn = "0", 71 | scrolloff = 5, 72 | sidescrolloff = 5, 73 | wrap = opts.wrap or false, 74 | cursorline = opts.cursorline or true, 75 | }) 76 | utils.set_focused_border_color(win, true) 77 | vim.cmd("redraw") 78 | end 79 | end 80 | component.close = function() 81 | if win and vim.api.nvim_win_is_valid(win) then 82 | vim.api.nvim_win_close(win, true) 83 | end 84 | end 85 | component.resize = function(new_width, new_height, new_col, new_row) 86 | if win and vim.api.nvim_win_is_valid(win) then 87 | vim.api.nvim_win_set_config(win, { 88 | relative = "editor", 89 | width = new_width, 90 | height = new_height, 91 | col = new_col, 92 | row = new_row, 93 | title = " " .. title .. " ", 94 | title_pos = "center", 95 | }) 96 | end 97 | end 98 | 99 | component.set_lines = function(lines, start_line, end_line) 100 | if buf and vim.api.nvim_buf_is_valid(buf) then 101 | start_line = start_line or 0 102 | end_line = end_line or -1 103 | 104 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 105 | vim.api.nvim_buf_set_lines(buf, start_line, end_line, false, lines) 106 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 107 | end 108 | end 109 | 110 | component.set_title = function(new_title) 111 | if win and vim.api.nvim_win_is_valid(win) then 112 | title = new_title 113 | local config = vim.api.nvim_win_get_config(win) 114 | config.title = " " .. new_title .. " " 115 | vim.api.nvim_win_set_config(win, config) 116 | end 117 | end 118 | 119 | if opts.mappings then 120 | utils.set_buffer_mappings(buf, opts.mappings) 121 | end 122 | 123 | return component 124 | end 125 | 126 | return M 127 | -------------------------------------------------------------------------------- /lua/neonuget/ui/details.lua: -------------------------------------------------------------------------------- 1 | local utils = require("neonuget.ui.utils") 2 | local baseui = require("neonuget.ui.baseui") 3 | 4 | local function split_lines_robust(str) 5 | if not str then 6 | return {} 7 | end 8 | local normalized_str = str:gsub("\r\n", "\n"):gsub("\r", "\n") 9 | local lines = {} 10 | local start = 1 11 | while true do 12 | local nl_pos = normalized_str:find("\n", start, true) 13 | if nl_pos then 14 | table.insert(lines, normalized_str:sub(start, nl_pos - 1)) 15 | start = nl_pos + 1 16 | else 17 | table.insert(lines, normalized_str:sub(start)) 18 | break 19 | end 20 | end 21 | return lines 22 | end 23 | 24 | local M = {} 25 | 26 | function M.create(opts) 27 | opts = opts or {} 28 | local pkg = opts.package 29 | local version = opts.version or (pkg and pkg.resolved_version) 30 | local width = opts.width or 40 31 | local height = opts.height or 20 32 | local col = opts.col or 0 33 | local row = opts.row or 0 34 | 35 | if not pkg then 36 | vim.notify("No package provided for details view", vim.log.levels.ERROR) 37 | return nil 38 | end 39 | 40 | local update_with_metadata 41 | local set_loading_state 42 | 43 | local function handle_tab() 44 | if opts.on_tab then 45 | opts.on_tab() 46 | end 47 | end 48 | 49 | local function handle_close() 50 | if opts.on_close then 51 | opts.on_close() 52 | end 53 | end 54 | 55 | local initial_content = { 56 | "Loading package details...", 57 | } 58 | 59 | local title = "Details: " .. pkg.name 60 | 61 | local component = baseui.create_component({ 62 | title = title, 63 | width = width, 64 | height = height, 65 | col = col, 66 | row = row, 67 | buffer_name = "NuGet_Package_Details_" .. pkg.name, 68 | initial_content = initial_content, 69 | wrap = true, 70 | cursorline = false, 71 | filetype = "markdown", 72 | focus = false, 73 | mappings = {}, 74 | }) 75 | 76 | if not component then 77 | return nil 78 | end 79 | 80 | utils.set_buffer_mappings(component.buf, { 81 | n = { 82 | [""] = handle_tab, 83 | ["q"] = handle_close, 84 | [""] = handle_close, 85 | }, 86 | }) 87 | 88 | update_with_metadata = function(metadata, version) 89 | if not component.buf or not vim.api.nvim_buf_is_valid(component.buf) then 90 | return 91 | end 92 | 93 | local buf = component.buf 94 | 95 | if component.win and vim.api.nvim_win_is_valid(component.win) and version then 96 | component.set_title("Details: " .. pkg.name) 97 | end 98 | 99 | if not metadata then 100 | vim.notify("Received nil metadata for " .. pkg.name, vim.log.levels.ERROR) 101 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 102 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "Failed to load details for " .. pkg.name }) 103 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 104 | return 105 | end 106 | 107 | local catalog_entry = metadata.catalogEntry 108 | local content = {} 109 | local highlights = {} 110 | local namespace_id = vim.api.nvim_create_namespace("neonuget_details_labels") 111 | 112 | local function add_section(label, value) 113 | if value and value ~= "" then 114 | local line_num = #content 115 | table.insert(content, label .. ":") 116 | table.insert(content, value) 117 | table.insert(content, "") 118 | table.insert(highlights, { line = line_num, group = "NuGetDetailsLabel" }) 119 | else 120 | local line_num = #content 121 | table.insert(content, label .. ":") 122 | table.insert(content, "Unknown") 123 | table.insert(content, "") 124 | table.insert(highlights, { line = line_num, group = "NuGetDetailsLabel" }) 125 | end 126 | end 127 | 128 | local function add_description(value) 129 | local line_num = #content 130 | table.insert(content, "Description:") 131 | table.insert(highlights, { line = line_num, group = "NuGetDetailsLabel" }) 132 | 133 | if value and value ~= "" then 134 | local description_lines = split_lines_robust(value) 135 | for _, line in ipairs(description_lines) do 136 | local trimmed_line = line:match("^%s*(.-)%s*$") 137 | table.insert(content, trimmed_line or "") 138 | end 139 | else 140 | table.insert(content, "No description available.") 141 | end 142 | table.insert(content, "") 143 | end 144 | 145 | local description = metadata.description 146 | if (not description or description == "") and catalog_entry then 147 | description = catalog_entry.description 148 | end 149 | add_description(description) 150 | 151 | local authors = metadata.authors 152 | if (not authors or authors == "") and catalog_entry then 153 | authors = catalog_entry.authors 154 | end 155 | add_section("Authors", authors) 156 | 157 | local published = metadata.published 158 | if (not published or published == "") and catalog_entry then 159 | published = catalog_entry.published 160 | end 161 | 162 | local published_date_str = "Unknown" 163 | if published and published ~= "" then 164 | local year, month, day = published:match("(%d+)-(%d+)-(%d+)") 165 | if year and month and day then 166 | published_date_str = year .. "-" .. month .. "-" .. day 167 | end 168 | end 169 | add_section("Published", published_date_str) 170 | 171 | local tags = metadata.tags 172 | if (not tags or (type(tags) == "table" and #tags == 0)) and catalog_entry then 173 | tags = catalog_entry.tags 174 | end 175 | 176 | local tags_str = "" 177 | if tags and type(tags) == "table" and #tags > 0 then 178 | tags_str = table.concat(tags, ", ") 179 | elseif tags and type(tags) == "string" and tags ~= "" then 180 | tags_str = tags 181 | end 182 | if tags_str ~= "" then 183 | add_section("Tags", tags_str) 184 | end 185 | 186 | local project_url = metadata.projectUrl 187 | if (not project_url or project_url == "") and catalog_entry then 188 | project_url = catalog_entry.projectUrl 189 | end 190 | add_section("Project", project_url) 191 | 192 | local license_url = metadata.licenseUrl 193 | if (not license_url or license_url == "") and catalog_entry then 194 | license_url = catalog_entry.licenseUrl 195 | end 196 | add_section("License", license_url) 197 | 198 | local download_count = metadata.totalDownloads 199 | if (not download_count or download_count == 0) and catalog_entry then 200 | download_count = catalog_entry.totalDownloads or catalog_entry.downloadCount 201 | end 202 | if download_count and download_count > 0 then 203 | add_section("Total Downloads", tostring(download_count)) 204 | end 205 | 206 | local dependencies = metadata.dependencies 207 | if (not dependencies or (type(dependencies) == "table" and #dependencies == 0)) and catalog_entry then 208 | dependencies = catalog_entry.dependencyGroups 209 | end 210 | 211 | if dependencies and type(dependencies) == "table" and #dependencies > 0 then 212 | local line_num = #content 213 | table.insert(content, "Dependencies:") 214 | table.insert(highlights, { line = line_num, group = "NuGetDetailsLabel" }) 215 | 216 | for _, dep_group in ipairs(dependencies) do 217 | local framework = dep_group.targetFramework or "Any" 218 | if dep_group.dependencies and #dep_group.dependencies > 0 then 219 | table.insert(content, " Framework: " .. framework) 220 | for _, dep in ipairs(dep_group.dependencies) do 221 | local dep_str = " - " .. dep.id 222 | if dep.range and dep.range ~= "" then 223 | dep_str = dep_str .. " (" .. dep.range .. ")" 224 | end 225 | table.insert(content, dep_str) 226 | end 227 | end 228 | end 229 | table.insert(content, "") 230 | end 231 | 232 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 233 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, content) 234 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 235 | 236 | vim.api.nvim_buf_clear_namespace(buf, namespace_id, 0, -1) 237 | 238 | for _, hl in ipairs(highlights) do 239 | vim.api.nvim_buf_add_highlight(buf, namespace_id, hl.group, hl.line, 0, -1) 240 | end 241 | end 242 | 243 | set_loading_state = function(version) 244 | if not component.buf or not vim.api.nvim_buf_is_valid(component.buf) then 245 | return 246 | end 247 | 248 | if component.win and vim.api.nvim_win_is_valid(component.win) and version then 249 | component.set_title("Details: " .. pkg.name) 250 | end 251 | 252 | vim.api.nvim_buf_set_option(component.buf, "modifiable", true) 253 | vim.api.nvim_buf_set_lines(component.buf, 0, -1, false, { 254 | "Loading details...", 255 | }) 256 | vim.api.nvim_buf_set_option(component.buf, "modifiable", false) 257 | end 258 | 259 | component.update = update_with_metadata 260 | component.set_loading = set_loading_state 261 | 262 | return component 263 | end 264 | 265 | return M 266 | -------------------------------------------------------------------------------- /lua/neonuget/ui/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local utils = require("neonuget.ui.utils") 4 | local search_component = require("neonuget.ui.search") 5 | local package_list_component = require("neonuget.ui.installed_package_list") 6 | local version_list_component = require("neonuget.ui.version_list") 7 | local details_component = require("neonuget.ui.details") 8 | local available_package_list_component = require("neonuget.ui.available_package_list") 9 | local nuget = require("neonuget.nuget") 10 | 11 | utils.setup_highlights() 12 | 13 | function M.display_dual_pane(packages, opts) 14 | opts = opts or {} 15 | 16 | local background = utils.create_background() 17 | 18 | local dimensions = utils.calculate_centered_dimensions(0.8, 0.8) 19 | local total_width = dimensions.width 20 | local total_height = dimensions.height 21 | local col = dimensions.col 22 | local row = dimensions.row 23 | 24 | local margin = 2 25 | 26 | local list_width = math.floor(total_width * 0.4) 27 | local details_width = total_width - list_width - 1 - margin 28 | local search_height = 1 29 | 30 | local versions_height = math.floor(total_height * 0.3) 31 | local details_height = total_height - versions_height - 1 - margin 32 | local right_pane_height = versions_height + details_height + 1 + margin 33 | 34 | local left_pane_height = right_pane_height 35 | local left_content_height = left_pane_height - search_height - margin * 2 - 2 36 | local installed_height = math.floor(left_content_height * 0.5) 37 | local available_height = left_content_height - installed_height 38 | 39 | local search_col, search_row = col, row 40 | local installed_col, installed_row = col, row + search_height + 1 + margin 41 | local available_col, available_row = col, row + search_height + 1 + margin + installed_height + margin + 1 42 | local versions_col, versions_row = col + list_width + 1 + margin, row 43 | 44 | local function get_search_pos() 45 | return search_col, search_row 46 | end 47 | 48 | local function get_installed_pos() 49 | return installed_col, installed_row 50 | end 51 | 52 | local function get_available_pos() 53 | return available_col, available_row 54 | end 55 | 56 | local active_components = { 57 | background = background, 58 | } 59 | 60 | _G.active_components = active_components 61 | 62 | local function reset_border_colors(focused_component) 63 | for component_name, component in pairs(active_components) do 64 | if 65 | component_name ~= "background" 66 | and component_name ~= focused_component 67 | and component 68 | and component.win 69 | then 70 | utils.set_focused_border_color(component.win, false) 71 | end 72 | end 73 | end 74 | 75 | local function close_all_components() 76 | for _, component in pairs(active_components) do 77 | if component and component.close then 78 | component.close() 79 | end 80 | end 81 | 82 | utils.close_windows_by_pattern({ 83 | "NuGet_Package_Versions_", 84 | "NuGet_Package_Details_", 85 | }) 86 | end 87 | 88 | local function expand_to_full_width() 89 | if active_components.search then 90 | local search_col, search_row = get_search_pos() 91 | active_components.search.resize(list_width, search_height, search_col, search_row) 92 | end 93 | 94 | if active_components.package_list then 95 | local installed_col, installed_row = get_installed_pos() 96 | active_components.package_list.resize(total_width, installed_height, installed_col, installed_row) 97 | end 98 | 99 | if active_components.available_package_list then 100 | local available_col, available_row = get_available_pos() 101 | active_components.available_package_list.resize(total_width, available_height, available_col, available_row) 102 | end 103 | end 104 | 105 | local function handle_package_selection(pkg) 106 | if active_components.version_list then 107 | active_components.version_list.close() 108 | active_components.version_list = nil 109 | end 110 | if active_components.details then 111 | active_components.details.close() 112 | active_components.details = nil 113 | end 114 | 115 | if active_components.package_list then 116 | active_components.package_list.resize(list_width, installed_height, installed_col, installed_row) 117 | end 118 | if active_components.available_package_list then 119 | active_components.available_package_list.resize(list_width, available_height, available_col, available_row) 120 | end 121 | if active_components.search then 122 | active_components.search.resize(list_width, search_height, search_col, search_row) 123 | end 124 | 125 | local version_list = version_list_component.create({ 126 | package = pkg, 127 | width = details_width, 128 | height = versions_height, 129 | col = versions_col, 130 | row = versions_row, 131 | on_select = function(version, metadata) 132 | if active_components.details then 133 | active_components.details.set_loading(version) 134 | 135 | if metadata then 136 | active_components.details.update(metadata, version) 137 | else 138 | nuget.fetch_package_metadata(pkg.name, version, function(api_metadata) 139 | if active_components.details then 140 | active_components.details.update(api_metadata, version) 141 | end 142 | end) 143 | end 144 | end 145 | end, 146 | on_enter = function() 147 | if active_components.details then 148 | reset_border_colors("details") 149 | active_components.details.focus() 150 | end 151 | end, 152 | on_tab = function() 153 | if active_components.details then 154 | reset_border_colors("details") 155 | active_components.details.focus() 156 | end 157 | end, 158 | on_close = function() 159 | if active_components.version_list then 160 | active_components.version_list.close() 161 | active_components.version_list = nil 162 | end 163 | if active_components.details then 164 | active_components.details.close() 165 | active_components.details = nil 166 | end 167 | expand_to_full_width() 168 | end, 169 | on_refresh = function(updated_packages) 170 | if active_components.package_list and updated_packages then 171 | active_components.package_list.update_packages(updated_packages) 172 | end 173 | end, 174 | on_set_loading = function(loading_message) 175 | if active_components.package_list then 176 | if loading_message then 177 | active_components.package_list.set_loading(loading_message) 178 | else 179 | active_components.package_list.clear_loading() 180 | end 181 | end 182 | end, 183 | }) 184 | 185 | if not version_list then 186 | return 187 | end 188 | active_components.version_list = version_list 189 | 190 | local details = details_component.create({ 191 | package = pkg, 192 | width = details_width, 193 | height = details_height, 194 | col = versions_col, 195 | row = versions_row + versions_height + 1 + margin, 196 | on_tab = function() 197 | if active_components.search then 198 | reset_border_colors("search") 199 | active_components.search.activate() 200 | end 201 | end, 202 | on_close = function() 203 | if active_components.version_list then 204 | active_components.version_list.close() 205 | active_components.version_list = nil 206 | end 207 | if active_components.details then 208 | active_components.details.close() 209 | active_components.details = nil 210 | end 211 | expand_to_full_width() 212 | end, 213 | }) 214 | if not details then 215 | return 216 | end 217 | active_components.details = details 218 | 219 | nuget.fetch_all_package_versions(pkg.name, function(versions_array) 220 | if not versions_array or #versions_array == 0 then 221 | vim.notify("No versions found for " .. pkg.name .. " during UI init", vim.log.levels.WARN) 222 | return 223 | end 224 | 225 | local version_info_map = {} 226 | for _, version_info in ipairs(versions_array) do 227 | version_info_map[version_info.version] = version_info 228 | end 229 | 230 | if active_components.version_list then 231 | active_components.version_list.version_info_map = version_info_map 232 | end 233 | 234 | if active_components.version_list then 235 | active_components.version_list.update(versions_array) 236 | end 237 | 238 | local initial_version = pkg.resolved_version 239 | if not initial_version and #versions_array > 0 then 240 | initial_version = versions_array[1].version 241 | end 242 | 243 | if initial_version and active_components.details then 244 | active_components.details.set_loading(initial_version) 245 | local metadata = version_info_map[initial_version] 246 | if metadata then 247 | active_components.details.update(metadata, initial_version) 248 | end 249 | end 250 | end) 251 | 252 | reset_border_colors("version_list") 253 | active_components.version_list.focus() 254 | end 255 | 256 | local search = search_component.create({ 257 | width = list_width, 258 | height = search_height, 259 | col = col, 260 | row = row, 261 | on_change = function(term) 262 | if active_components.package_list then 263 | active_components.package_list.update(term) 264 | end 265 | 266 | if active_components.available_package_list then 267 | active_components.available_package_list.update_params({ q = term }) 268 | end 269 | end, 270 | on_enter = function() 271 | if active_components.package_list then 272 | reset_border_colors("package_list") 273 | active_components.package_list.focus() 274 | end 275 | end, 276 | on_escape = function() 277 | if active_components.package_list then 278 | active_components.package_list.focus() 279 | end 280 | end, 281 | on_tab = function() 282 | if active_components.package_list then 283 | reset_border_colors("package_list") 284 | active_components.package_list.focus() 285 | end 286 | end, 287 | on_close = close_all_components, 288 | }) 289 | 290 | if not search then 291 | close_all_components() 292 | return nil 293 | end 294 | 295 | active_components.search = search 296 | 297 | local package_list = package_list_component.create({ 298 | packages = packages or {}, 299 | width = total_width, 300 | height = installed_height, 301 | col = col, 302 | row = (select(2, get_installed_pos())), 303 | on_select = function(pkg) 304 | handle_package_selection(pkg) 305 | end, 306 | on_search = function() 307 | if active_components.search then 308 | active_components.search.activate() 309 | end 310 | end, 311 | on_tab = function() 312 | if active_components.available_package_list then 313 | reset_border_colors("available_package_list") 314 | active_components.available_package_list.focus() 315 | elseif active_components.version_list then 316 | reset_border_colors("version_list") 317 | active_components.version_list.focus() 318 | elseif active_components.details then 319 | reset_border_colors("details") 320 | active_components.details.focus() 321 | else 322 | if active_components.search then 323 | reset_border_colors("search") 324 | active_components.search.activate() 325 | end 326 | end 327 | end, 328 | on_close = close_all_components, 329 | }) 330 | 331 | if not package_list then 332 | close_all_components() 333 | return nil 334 | end 335 | 336 | active_components.package_list = package_list 337 | 338 | local available_package_list = available_package_list_component.create({ 339 | width = total_width, 340 | height = available_height, 341 | col = col, 342 | row = (select(2, get_available_pos())), 343 | params = { 344 | q = "", 345 | take = 50, 346 | prerelease = false, 347 | semVerLevel = "2.0.0", 348 | skip = 0, 349 | sortBy = "relevance", 350 | }, 351 | on_select = function(pkg) 352 | handle_package_selection(pkg) 353 | end, 354 | on_search = function() 355 | if active_components.search then 356 | active_components.search.activate() 357 | end 358 | end, 359 | on_tab = function() 360 | if active_components.version_list then 361 | reset_border_colors("version_list") 362 | active_components.version_list.focus() 363 | elseif active_components.details then 364 | reset_border_colors("details") 365 | active_components.details.focus() 366 | elseif active_components.search then 367 | reset_border_colors("search") 368 | active_components.search.activate() 369 | end 370 | end, 371 | on_close = close_all_components, 372 | }) 373 | 374 | if not available_package_list then 375 | close_all_components() 376 | return nil 377 | end 378 | 379 | active_components.available_package_list = available_package_list 380 | 381 | if packages then 382 | package_list.update_packages(packages) 383 | end 384 | 385 | if active_components.search then 386 | reset_border_colors("search") 387 | active_components.search.activate() 388 | end 389 | 390 | return { 391 | components = active_components, 392 | close = close_all_components, 393 | } 394 | end 395 | 396 | function M.display_package_details_split(pkg, metadata) 397 | if not pkg then 398 | vim.notify("No package provided", vim.log.levels.ERROR) 399 | return nil 400 | end 401 | 402 | local background = utils.create_background() 403 | 404 | local dimensions = utils.calculate_centered_dimensions(0.8, 0.8) 405 | local width = dimensions.width 406 | local height = dimensions.height 407 | local col = dimensions.col 408 | local row = dimensions.row 409 | 410 | local versions_height = math.floor(height * 0.3) 411 | local details_height = height - versions_height - 1 412 | 413 | local active_components = { 414 | background = background, 415 | } 416 | 417 | _G.active_components = active_components 418 | 419 | local function reset_border_colors(focused_component) 420 | for component_name, component in pairs(active_components) do 421 | if 422 | component_name ~= "background" 423 | and component_name ~= focused_component 424 | and component 425 | and component.win 426 | then 427 | utils.set_focused_border_color(component.win, false) 428 | end 429 | end 430 | end 431 | 432 | local function close_all_components() 433 | for _, component in pairs(active_components) do 434 | if component and component.close then 435 | component.close() 436 | end 437 | end 438 | end 439 | 440 | local version_list = version_list_component.create({ 441 | package = pkg, 442 | width = width, 443 | height = versions_height, 444 | col = col, 445 | row = row, 446 | on_select = function(version, metadata) 447 | if active_components.details then 448 | active_components.details.set_loading(version) 449 | 450 | if metadata then 451 | active_components.details.update(metadata, version) 452 | else 453 | nuget.fetch_package_metadata(pkg.name, version, function(ver_metadata) 454 | if active_components.details then 455 | active_components.details.update(ver_metadata, version) 456 | end 457 | end) 458 | end 459 | end 460 | end, 461 | on_enter = function() 462 | if active_components.details then 463 | reset_border_colors("details") 464 | active_components.details.focus() 465 | end 466 | end, 467 | on_tab = function() 468 | if active_components.version_list then 469 | reset_border_colors("version_list") 470 | active_components.version_list.focus() 471 | end 472 | end, 473 | on_close = close_all_components, 474 | }) 475 | 476 | if not version_list then 477 | background.close() 478 | return nil 479 | end 480 | 481 | active_components.version_list = version_list 482 | 483 | local details = details_component.create({ 484 | package = pkg, 485 | width = width, 486 | height = details_height, 487 | col = col, 488 | row = row + versions_height + 1, 489 | on_tab = function() 490 | if active_components.version_list then 491 | reset_border_colors("version_list") 492 | active_components.version_list.focus() 493 | end 494 | end, 495 | on_close = close_all_components, 496 | }) 497 | 498 | if not details then 499 | close_all_components() 500 | return nil 501 | end 502 | 503 | active_components.details = details 504 | 505 | if metadata then 506 | details.update(metadata, pkg.resolved_version) 507 | end 508 | 509 | nuget.fetch_all_package_versions(pkg.name, function(versions_array) 510 | if not versions_array or #versions_array == 0 then 511 | vim.notify("No versions found for " .. pkg.name .. " during UI init", vim.log.levels.WARN) 512 | return 513 | end 514 | 515 | local version_info_map = {} 516 | for _, version_info in ipairs(versions_array) do 517 | version_info_map[version_info.version] = version_info 518 | end 519 | 520 | if active_components.version_list then 521 | active_components.version_list.version_info_map = version_info_map 522 | end 523 | 524 | if active_components.version_list then 525 | active_components.version_list.update(versions_array) 526 | end 527 | 528 | local initial_version = pkg.resolved_version 529 | if not initial_version and #versions_array > 0 then 530 | initial_version = versions_array[1].version -- Newest version first 531 | end 532 | 533 | if initial_version and active_components.details and not metadata then 534 | active_components.details.set_loading(initial_version) 535 | 536 | local version_metadata = version_info_map[initial_version] 537 | if version_metadata then 538 | active_components.details.update(version_metadata, initial_version) 539 | end 540 | end 541 | end) 542 | 543 | return { 544 | components = active_components, 545 | close = close_all_components, 546 | } 547 | end 548 | 549 | M.create_buffer = utils.create_buffer 550 | 551 | return M 552 | -------------------------------------------------------------------------------- /lua/neonuget/ui/installed_package_list.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local utils = require("neonuget.ui.utils") 4 | local baseui = require("neonuget.ui.baseui") 5 | 6 | function M.create(opts) 7 | opts = opts or {} 8 | local packages = opts.packages or {} 9 | local width = opts.width or 30 10 | local height = opts.height or 20 11 | local col = opts.col or 0 12 | local row = opts.row or 0 13 | local on_select = opts.on_select or function() end 14 | 15 | local list_highlight_namespace_id = vim.api.nvim_create_namespace("neonuget_installed_list_highlights") 16 | 17 | local function sort_packages(pkgs) 18 | table.sort(pkgs, function(a, b) 19 | return a.name < b.name 20 | end) 21 | return pkgs 22 | end 23 | 24 | local top_level_packages = {} 25 | 26 | for _, pkg in ipairs(packages) do 27 | if pkg.is_top_level or pkg.section == "Top-level" then 28 | table.insert(top_level_packages, pkg) 29 | end 30 | end 31 | 32 | top_level_packages = sort_packages(top_level_packages) 33 | local original_packages = vim.deepcopy(top_level_packages) 34 | 35 | local package_lookup = {} 36 | local package_indices = {} 37 | local last_selected_pkg_name = nil 38 | 39 | local function filter_packages(term) 40 | if not term or term == "" then 41 | return original_packages 42 | end 43 | 44 | local filtered = {} 45 | term = term:lower() 46 | 47 | for _, pkg in ipairs(original_packages) do 48 | if pkg.name:lower():find(term, 1, true) then 49 | table.insert(filtered, pkg) 50 | end 51 | end 52 | 53 | return filtered 54 | end 55 | 56 | local function update_list_display(search_term, target_component) 57 | local comp = target_component or component 58 | 59 | if not comp then 60 | return {}, {} 61 | end 62 | 63 | local filtered_packages = filter_packages(search_term or "") 64 | local list_content = {} 65 | local line_highlights = {} -- To store highlight info 66 | local new_package_lookup = {} 67 | local new_package_indices = {} 68 | 69 | -- If we have no packages yet, show loading state 70 | if #original_packages == 0 then 71 | list_content = { "Loading installed packages..." } 72 | else 73 | for i, pkg in ipairs(filtered_packages) do 74 | local base_line = pkg.name .. " (" .. pkg.resolved_version .. ")" 75 | local full_line = base_line 76 | 77 | if pkg.latest_version and pkg.latest_version ~= "" and pkg.latest_version ~= pkg.resolved_version then 78 | local update_suffix = " -> " .. pkg.latest_version 79 | full_line = base_line .. update_suffix 80 | 81 | table.insert(line_highlights, { 82 | line = #list_content, -- 0-indexed line for nvim_buf_add_highlight 83 | start_col = string.len(base_line), -- 0-indexed start col of the suffix 84 | end_col = string.len(full_line), -- 0-indexed end col (exclusive) of the suffix 85 | group = "NuGetUpdateAvailable", 86 | }) 87 | end 88 | 89 | table.insert(list_content, full_line) 90 | new_package_lookup[i] = pkg 91 | new_package_indices[#list_content] = i 92 | end 93 | 94 | if #filtered_packages == 0 then 95 | list_content = { "No packages found matching: " .. (search_term or "") } 96 | end 97 | end 98 | 99 | if comp.buf and vim.api.nvim_buf_is_valid(comp.buf) then 100 | comp.set_lines(list_content) 101 | 102 | -- Apply highlights 103 | vim.api.nvim_buf_clear_namespace(comp.buf, list_highlight_namespace_id, 0, -1) 104 | for _, hl in ipairs(line_highlights) do 105 | vim.api.nvim_buf_add_highlight(comp.buf, list_highlight_namespace_id, hl.group, hl.line, hl.start_col, hl.end_col) 106 | end 107 | end 108 | 109 | return new_package_lookup, new_package_indices 110 | end 111 | 112 | local function handle_enter(buf, win) 113 | if not win or not vim.api.nvim_win_is_valid(win) then 114 | return 115 | end 116 | 117 | local cursor = vim.api.nvim_win_get_cursor(win) 118 | local line_num = cursor[1] 119 | 120 | if line_num <= 0 or line_num > vim.api.nvim_buf_line_count(buf) then 121 | return 122 | end 123 | 124 | if 125 | package_indices 126 | and package_indices[line_num] 127 | and package_lookup 128 | and package_lookup[package_indices[line_num]] 129 | then 130 | local selected_pkg = package_lookup[package_indices[line_num]] 131 | last_selected_pkg_name = selected_pkg.name 132 | on_select(selected_pkg) 133 | end 134 | end 135 | 136 | local function handle_search() 137 | if opts.on_search then 138 | opts.on_search() 139 | end 140 | end 141 | 142 | local function handle_tab() 143 | if opts.on_tab then 144 | opts.on_tab() 145 | end 146 | end 147 | 148 | local function handle_close() 149 | if opts.on_close then 150 | opts.on_close() 151 | end 152 | end 153 | 154 | local component = baseui.create_component({ 155 | title = "Installed Packages", 156 | width = width, 157 | height = height, 158 | col = col, 159 | row = row, 160 | buffer_name = "NuGet_Package_List", 161 | initial_content = { "Loading packages..." }, 162 | mappings = {}, 163 | }) 164 | 165 | if not component then 166 | return nil 167 | end 168 | 169 | utils.set_buffer_mappings(component.buf, { 170 | n = { 171 | [""] = function() 172 | handle_enter(component.buf, component.win) 173 | end, 174 | ["/"] = handle_search, 175 | [""] = handle_tab, 176 | ["q"] = handle_close, 177 | [""] = handle_close, 178 | ["u"] = function() 179 | local selected_pkg = component.get_selected_package() 180 | if not selected_pkg then 181 | vim.notify("No package selected", vim.log.levels.WARN) 182 | return 183 | end 184 | 185 | vim.ui.select({ "Yes", "No" }, { prompt = "Uninstall " .. selected_pkg.name .. "?" }, function(choice) 186 | if choice == "Yes" then 187 | component.set_loading("Uninstalling " .. selected_pkg.name .. "...") 188 | 189 | require("neonuget").uninstall_package(selected_pkg.name, function(success, packages) 190 | component.clear_loading() 191 | 192 | if success then 193 | component._select_first_after_update = true 194 | if packages then 195 | component.update_packages(packages) 196 | else 197 | require("neonuget").refresh_packages(function(refreshed_packages) 198 | component.update_packages(refreshed_packages) 199 | end) 200 | end 201 | end 202 | end) 203 | end 204 | end) 205 | end, 206 | }, 207 | }) 208 | 209 | package_lookup, package_indices = update_list_display("", component) 210 | 211 | if #top_level_packages > 0 and package_lookup[1] then 212 | if package_indices[1] and component.win and vim.api.nvim_win_is_valid(component.win) then 213 | for line_num, idx in pairs(package_indices) do 214 | if idx == 1 then 215 | if line_num > 0 and line_num <= vim.api.nvim_buf_line_count(component.buf) then 216 | vim.api.nvim_win_set_cursor(component.win, { line_num, 0 }) 217 | break 218 | end 219 | end 220 | end 221 | end 222 | end 223 | 224 | component.update = function(search_term) 225 | package_lookup, package_indices = update_list_display(search_term, component) 226 | end 227 | 228 | component.update_packages = function(new_packages) 229 | local top_level_packages = {} 230 | 231 | for _, pkg in ipairs(new_packages) do 232 | if pkg.is_top_level or pkg.section == "Top-level" then 233 | table.insert(top_level_packages, pkg) 234 | end 235 | end 236 | 237 | top_level_packages = sort_packages(top_level_packages) 238 | original_packages = vim.deepcopy(top_level_packages) 239 | 240 | package_lookup, package_indices = update_list_display("", component) 241 | 242 | if component.win and vim.api.nvim_win_is_valid(component.win) then 243 | utils.configure_window(component.win, { 244 | number = false, 245 | relativenumber = false, 246 | signcolumn = "no", 247 | foldcolumn = "0", 248 | scrolloff = 5, 249 | sidescrolloff = 5, 250 | wrap = opts.wrap or false, 251 | cursorline = opts.cursorline or true, 252 | }) 253 | end 254 | 255 | if component._select_first_after_update and #top_level_packages > 0 and package_lookup[1] then 256 | component._select_first_after_update = nil 257 | 258 | for line_num, pkg_idx in pairs(package_indices) do 259 | if pkg_idx == 1 then 260 | if 261 | component.win 262 | and vim.api.nvim_win_is_valid(component.win) 263 | and line_num > 0 264 | and line_num <= vim.api.nvim_buf_line_count(component.buf) 265 | then 266 | vim.api.nvim_win_set_cursor(component.win, { line_num, 0 }) 267 | on_select(package_lookup[1]) 268 | last_selected_pkg_name = package_lookup[1].name 269 | break 270 | end 271 | end 272 | end 273 | elseif component._select_after_update or last_selected_pkg_name then 274 | local package_to_select = component._select_after_update or last_selected_pkg_name 275 | component._select_after_update = nil 276 | 277 | if package_to_select then 278 | local found = false 279 | 280 | for idx, pkg in pairs(package_lookup) do 281 | if pkg.name == package_to_select then 282 | for line_num, pkg_idx in pairs(package_indices) do 283 | if pkg_idx == idx then 284 | if 285 | component.win 286 | and vim.api.nvim_win_is_valid(component.win) 287 | and line_num > 0 288 | and line_num <= vim.api.nvim_buf_line_count(component.buf) 289 | then 290 | vim.api.nvim_win_set_cursor(component.win, { line_num, 0 }) 291 | on_select(pkg) -- Select the package to update version list 292 | found = true 293 | break 294 | end 295 | end 296 | end 297 | if found then 298 | break 299 | end 300 | end 301 | end 302 | end 303 | end 304 | end 305 | 306 | component.set_loading = function(message) 307 | if component.buf and vim.api.nvim_buf_is_valid(component.buf) then 308 | component._saved_content = vim.api.nvim_buf_get_lines(component.buf, 0, -1, false) 309 | vim.api.nvim_buf_set_option(component.buf, "modifiable", true) 310 | vim.api.nvim_buf_set_lines(component.buf, 0, -1, false, { message or "Loading..." }) 311 | vim.api.nvim_buf_set_option(component.buf, "modifiable", false) 312 | end 313 | end 314 | 315 | component.clear_loading = function() 316 | if component.buf and vim.api.nvim_buf_is_valid(component.buf) and component._saved_content then 317 | vim.api.nvim_buf_set_option(component.buf, "modifiable", true) 318 | vim.api.nvim_buf_set_lines(component.buf, 0, -1, false, component._saved_content) 319 | vim.api.nvim_buf_set_option(component.buf, "modifiable", false) 320 | component._saved_content = nil 321 | 322 | if component.win and vim.api.nvim_win_is_valid(component.win) then 323 | utils.configure_window(component.win, { 324 | number = false, 325 | relativenumber = false, 326 | signcolumn = "no", 327 | foldcolumn = "0", 328 | scrolloff = 5, 329 | sidescrolloff = 5, 330 | wrap = opts.wrap or false, 331 | cursorline = opts.cursorline or true, 332 | }) 333 | end 334 | end 335 | end 336 | 337 | component.get_selected_package = function() 338 | if not component.win or not vim.api.nvim_win_is_valid(component.win) then 339 | return nil 340 | end 341 | 342 | local cursor = vim.api.nvim_win_get_cursor(component.win) 343 | local line_num = cursor[1] 344 | 345 | if line_num <= 0 or line_num > vim.api.nvim_buf_line_count(component.buf) then 346 | return nil 347 | end 348 | 349 | if 350 | package_indices 351 | and package_indices[line_num] 352 | and package_lookup 353 | and package_lookup[package_indices[line_num]] 354 | then 355 | return package_lookup[package_indices[line_num]] 356 | end 357 | 358 | return nil 359 | end 360 | 361 | return component 362 | end 363 | 364 | return M 365 | -------------------------------------------------------------------------------- /lua/neonuget/ui/search.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local utils = require("neonuget.ui.utils") 4 | local baseui = require("neonuget.ui.baseui") 5 | 6 | function M.create(opts) 7 | opts = opts or {} 8 | local width = opts.width or 30 9 | local height = 1 10 | local col = opts.col or 0 11 | local row = opts.row or 0 12 | local on_change = opts.on_change or function() end 13 | 14 | local component = baseui.create_component({ 15 | title = "Search", 16 | width = width, 17 | height = height, 18 | col = col, 19 | row = row, 20 | buffer_name = "NuGet_Search", 21 | initial_content = { "" }, 22 | focus = false, 23 | cursorline = false, 24 | number = false, 25 | relativenumber = false, 26 | mappings = { 27 | i = { 28 | [""] = function() 29 | vim.cmd("stopinsert") 30 | if opts.on_tab then 31 | opts.on_tab() 32 | end 33 | end, 34 | [""] = function() 35 | vim.cmd("stopinsert") 36 | if opts.on_escape then 37 | opts.on_escape() 38 | end 39 | end, 40 | [""] = function() 41 | vim.cmd("stopinsert") 42 | if opts.on_enter then 43 | opts.on_enter() 44 | end 45 | end, 46 | }, 47 | n = { 48 | [""] = function() 49 | if opts.on_tab then 50 | opts.on_tab() 51 | end 52 | end, 53 | ["q"] = function() 54 | if opts.on_close then 55 | opts.on_close() 56 | end 57 | end, 58 | [""] = function() 59 | if opts.on_escape then 60 | opts.on_escape() 61 | end 62 | end, 63 | ["i"] = function() 64 | component.activate() 65 | end, 66 | }, 67 | }, 68 | }) 69 | 70 | if not component then 71 | return nil 72 | end 73 | 74 | local search_timer = nil 75 | local last_search_term = nil 76 | 77 | local function start_search_updates() 78 | if search_timer then 79 | search_timer:stop() 80 | end 81 | search_timer = vim.loop.new_timer() 82 | search_timer:start( 83 | 0, 84 | 150, 85 | vim.schedule_wrap(function() 86 | if not component.win or not vim.api.nvim_win_is_valid(component.win) then 87 | return 88 | end 89 | 90 | local mode = vim.api.nvim_get_mode().mode 91 | if mode ~= "i" then 92 | return 93 | end 94 | 95 | local current_line = vim.api.nvim_buf_get_lines(component.buf, 0, 1, false)[1] or "" 96 | 97 | if current_line ~= last_search_term then 98 | on_change(current_line) 99 | last_search_term = current_line 100 | end 101 | end) 102 | ) 103 | end 104 | 105 | local function stop_search_updates() 106 | if search_timer then 107 | search_timer:stop() 108 | search_timer:close() 109 | search_timer = nil 110 | end 111 | last_search_term = nil 112 | end 113 | 114 | component.activate = function() 115 | if not component.win or not vim.api.nvim_win_is_valid(component.win) then 116 | return 117 | end 118 | 119 | vim.api.nvim_set_current_win(component.win) 120 | vim.api.nvim_buf_set_option(component.buf, "modifiable", true) 121 | vim.cmd("startinsert") 122 | 123 | utils.configure_window(component.win, { 124 | number = false, 125 | relativenumber = false, 126 | }) 127 | 128 | utils.set_focused_border_color(component.win, true) 129 | 130 | vim.cmd("redraw") 131 | end 132 | 133 | local original_close = component.close 134 | component.close = function() 135 | stop_search_updates() 136 | original_close() 137 | end 138 | 139 | component.start_updates = start_search_updates 140 | component.stop_updates = stop_search_updates 141 | 142 | local augroup = vim.api.nvim_create_augroup("NuGetSearchTimer_" .. component.buf, { clear = true }) 143 | vim.api.nvim_create_autocmd("InsertEnter", { 144 | group = augroup, 145 | buffer = component.buf, 146 | callback = start_search_updates, 147 | }) 148 | vim.api.nvim_create_autocmd("InsertLeave", { 149 | group = augroup, 150 | buffer = component.buf, 151 | callback = stop_search_updates, 152 | }) 153 | 154 | return component 155 | end 156 | 157 | return M 158 | -------------------------------------------------------------------------------- /lua/neonuget/ui/utils.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.create_buffer(name) 4 | local buf = vim.api.nvim_create_buf(false, true) 5 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 6 | vim.api.nvim_buf_set_option(buf, "buftype", "nofile") 7 | vim.api.nvim_buf_set_option(buf, "swapfile", false) 8 | vim.api.nvim_buf_set_option(buf, "filetype", "neonuget") 9 | 10 | if name then 11 | local unique_id = os.time() 12 | local unique_name = string.format("%s_%s", name, unique_id) 13 | 14 | local ok, err = pcall(function() 15 | vim.api.nvim_buf_set_name(buf, unique_name) 16 | end) 17 | 18 | if not ok then 19 | vim.notify("Warning: Could not set buffer name. " .. tostring(err), vim.log.levels.WARN) 20 | end 21 | end 22 | 23 | return buf 24 | end 25 | 26 | function M.configure_window(win, options) 27 | if not win or not vim.api.nvim_win_is_valid(win) then 28 | return 29 | end 30 | 31 | for option, value in pairs(options) do 32 | vim.api.nvim_win_set_option(win, option, value) 33 | end 34 | end 35 | 36 | function M.set_buffer_mappings(buf, mappings) 37 | if not buf or not vim.api.nvim_buf_is_valid(buf) then 38 | return 39 | end 40 | 41 | for mode, mode_mappings in pairs(mappings) do 42 | for key, mapping in pairs(mode_mappings) do 43 | vim.api.nvim_buf_set_keymap(buf, mode, key, "", { 44 | noremap = true, 45 | silent = true, 46 | callback = mapping, 47 | }) 48 | end 49 | end 50 | end 51 | 52 | function M.create_background() 53 | local background_buf = M.create_buffer("NuGet_Background") 54 | vim.cmd([[highlight NuGetBg guibg=#000000 guifg=NONE blend=30]]) 55 | 56 | vim.api.nvim_buf_set_option(background_buf, "modifiable", true) 57 | local bg_lines = {} 58 | for _ = 1, vim.o.lines do 59 | table.insert(bg_lines, string.rep(" ", vim.o.columns)) 60 | end 61 | vim.api.nvim_buf_set_lines(background_buf, 0, -1, false, bg_lines) 62 | vim.api.nvim_buf_set_option(background_buf, "modifiable", false) 63 | 64 | local background_win = vim.api.nvim_open_win(background_buf, false, { 65 | relative = "editor", 66 | width = vim.o.columns, 67 | height = vim.o.lines, 68 | col = 0, 69 | row = 0, 70 | style = "minimal", 71 | focusable = false, 72 | zindex = 10, 73 | }) 74 | 75 | if background_win and vim.api.nvim_win_is_valid(background_win) then 76 | vim.api.nvim_win_set_option(background_win, "winblend", 30) 77 | vim.api.nvim_win_set_option(background_win, "winhighlight", "Normal:NuGetBg") 78 | end 79 | 80 | return { 81 | buf = background_buf, 82 | win = background_win, 83 | close = function() 84 | if background_win and vim.api.nvim_win_is_valid(background_win) then 85 | vim.api.nvim_win_close(background_win, true) 86 | end 87 | end, 88 | } 89 | end 90 | 91 | function M.calculate_centered_dimensions(width_percent, height_percent) 92 | local width = math.min(math.floor(vim.o.columns * (width_percent or 0.8)), vim.o.columns - 4) 93 | local height = math.min(math.floor(vim.o.lines * (height_percent or 0.8)), vim.o.lines - 4) 94 | local col = math.floor((vim.o.columns - width) / 2) 95 | local row = math.floor((vim.o.lines - height) / 2) 96 | 97 | return { 98 | width = width, 99 | height = height, 100 | col = col, 101 | row = row, 102 | } 103 | end 104 | 105 | function M.close_windows_by_pattern(patterns) 106 | patterns = patterns or {} 107 | local windows_to_close = {} 108 | 109 | for _, win in ipairs(vim.api.nvim_list_wins()) do 110 | local buf = vim.api.nvim_win_get_buf(win) 111 | local name = vim.api.nvim_buf_get_name(buf) 112 | 113 | for _, pattern in ipairs(patterns) do 114 | if name:match(pattern) then 115 | table.insert(windows_to_close, win) 116 | break 117 | end 118 | end 119 | end 120 | 121 | for _, win in ipairs(windows_to_close) do 122 | if vim.api.nvim_win_is_valid(win) then 123 | vim.api.nvim_win_close(win, true) 124 | end 125 | end 126 | end 127 | 128 | function M.setup_highlights() 129 | vim.api.nvim_set_hl(0, "NuGetFocusedBorder", { fg = "#F9B387", bold = true, sp = "#F9B387" }) 130 | vim.api.nvim_set_hl(0, "NuGetDetailsLabel", { bold = true }) 131 | end 132 | 133 | function M.set_focused_border_color(win, focused) 134 | if not win or not vim.api.nvim_win_is_valid(win) then 135 | return 136 | end 137 | if focused then 138 | vim.api.nvim_win_set_option(win, "winhl", "NormalFloat:Normal,FloatBorder:NuGetFocusedBorder") 139 | else 140 | vim.api.nvim_win_set_option(win, "winhl", "") 141 | end 142 | end 143 | 144 | return M 145 | -------------------------------------------------------------------------------- /lua/neonuget/ui/version_list.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local utils = require("neonuget.ui.utils") 4 | local baseui = require("neonuget.ui.baseui") 5 | 6 | function M.create(opts) 7 | opts = opts or {} 8 | local pkg = opts.package 9 | local width = opts.width or 40 10 | local height = opts.height or 10 11 | local col = opts.col or 0 12 | local row = opts.row or 0 13 | local on_select = opts.on_select or function() end 14 | 15 | local active_components = _G.active_components 16 | 17 | if not pkg then 18 | vim.notify("No package provided for version list", vim.log.levels.ERROR) 19 | return nil 20 | end 21 | 22 | local version_data = {} 23 | local current_line = 0 24 | local update_versions 25 | 26 | local component = baseui.create_component({ 27 | title = "Versions: " .. pkg.name, 28 | width = width, 29 | height = height, 30 | col = col, 31 | row = row, 32 | buffer_name = "NuGet_Package_Versions_" .. pkg.name, 33 | initial_content = { "Loading versions..." }, 34 | wrap = true, 35 | cursorline = true, 36 | filetype = "markdown", 37 | focus = true, 38 | mappings = {}, 39 | }) 40 | 41 | if not component then 42 | return nil 43 | end 44 | 45 | local nuget = require("neonuget.nuget") 46 | 47 | update_versions = function(versions_array) 48 | if not component.buf or not vim.api.nvim_buf_is_valid(component.buf) then 49 | return 50 | end 51 | 52 | if not versions_array or #versions_array == 0 then 53 | vim.api.nvim_buf_set_option(component.buf, "modifiable", true) 54 | vim.api.nvim_buf_set_lines(component.buf, 0, -1, false, { "No versions found for " .. pkg.name }) 55 | vim.api.nvim_buf_set_option(component.buf, "modifiable", false) 56 | return 57 | end 58 | 59 | local content = {} 60 | 61 | version_data = {} 62 | current_line = 1 63 | 64 | local standardized_versions = {} 65 | for i, v in ipairs(versions_array) do 66 | local version_text 67 | local version_obj = {} 68 | 69 | if type(v) == "string" then 70 | -- Simple string version 71 | version_text = v 72 | version_obj = { version = v } 73 | elseif type(v) == "table" then 74 | if v.text then 75 | version_text = v.text 76 | version_obj = v.data or v 77 | elseif v.version then 78 | version_text = v.version 79 | version_obj = v 80 | else 81 | vim.notify("Unknown version format: " .. vim.inspect(v), vim.log.levels.DEBUG) 82 | goto continue 83 | end 84 | else 85 | vim.notify("Unknown version type: " .. type(v), vim.log.levels.DEBUG) 86 | goto continue 87 | end 88 | 89 | if not version_text or version_text == "" then 90 | goto continue 91 | end 92 | 93 | table.insert(standardized_versions, { 94 | text = version_text, 95 | data = version_obj, 96 | }) 97 | 98 | ::continue:: 99 | end 100 | 101 | table.sort(standardized_versions, function(a, b) 102 | return a.text > b.text 103 | end) 104 | 105 | for i, v in ipairs(standardized_versions) do 106 | local version = v.text 107 | local version_info = v.data 108 | local line_num = #content + 1 109 | 110 | local display_text = version 111 | 112 | if version == pkg.resolved_version then 113 | display_text = display_text .. " (current)" 114 | current_line = line_num 115 | end 116 | 117 | if i == 1 then 118 | display_text = display_text .. " (latest)" 119 | end 120 | 121 | table.insert(content, display_text) 122 | version_data[line_num] = version 123 | 124 | if not component.version_info_map then 125 | component.version_info_map = {} 126 | end 127 | component.version_info_map[version] = version_info 128 | end 129 | 130 | vim.api.nvim_buf_set_option(component.buf, "modifiable", true) 131 | vim.api.nvim_buf_set_lines(component.buf, 0, -1, false, content) 132 | vim.api.nvim_buf_set_option(component.buf, "modifiable", false) 133 | 134 | if component.win and vim.api.nvim_win_is_valid(component.win) and current_line > 0 then 135 | vim.api.nvim_win_set_cursor(component.win, { current_line, 0 }) 136 | end 137 | end 138 | 139 | local augroup_name = "NuGetVersionSelect_" .. pkg.name 140 | local augroup = vim.api.nvim_create_augroup(augroup_name, { clear = true }) 141 | vim.api.nvim_create_autocmd("CursorMoved", { 142 | group = augroup, 143 | buffer = component.buf, 144 | callback = function() 145 | if component.win and vim.api.nvim_win_is_valid(component.win) then 146 | local cursor = vim.api.nvim_win_get_cursor(component.win) 147 | local line = cursor[1] 148 | local version = version_data[line] 149 | 150 | if version then 151 | on_select(version) 152 | end 153 | end 154 | end, 155 | }) 156 | 157 | local original_close = component.close 158 | component.close = function() 159 | pcall(vim.api.nvim_del_augroup_by_name, augroup_name) 160 | original_close() 161 | end 162 | 163 | component.update = update_versions 164 | 165 | component.get_selected_version = function() 166 | if not component.win or not vim.api.nvim_win_is_valid(component.win) then 167 | return nil 168 | end 169 | 170 | local cursor = vim.api.nvim_win_get_cursor(component.win) 171 | local line = cursor[1] 172 | 173 | return version_data[line] 174 | end 175 | 176 | component.setup_key_handlers = function() 177 | vim.api.nvim_buf_set_keymap(component.buf, "n", "", "", { 178 | callback = function() 179 | if opts.on_enter then 180 | opts.on_enter() 181 | end 182 | end, 183 | noremap = true, 184 | silent = true, 185 | }) 186 | 187 | vim.api.nvim_buf_set_keymap(component.buf, "n", "i", "", { 188 | callback = function() 189 | local selected_version = component.get_selected_version() 190 | local pkg_name = pkg.name 191 | 192 | if selected_version and pkg_name then 193 | if active_components and active_components.package_list then 194 | active_components.package_list.set_loading( 195 | "Installing " .. pkg_name .. " " .. selected_version .. "..." 196 | ) 197 | end 198 | 199 | require("neonuget").install_package(pkg_name, selected_version, function(success) 200 | if active_components and active_components.package_list then 201 | active_components.package_list.clear_loading() 202 | 203 | if success then 204 | require("neonuget").refresh_packages(function(packages) 205 | if active_components and active_components.package_list then 206 | active_components.package_list._select_after_update = pkg_name 207 | active_components.package_list.update_packages(packages) 208 | end 209 | 210 | if active_components then 211 | for _, comp in pairs(active_components) do 212 | if comp and comp.win and vim.api.nvim_win_is_valid(comp.win) then 213 | utils.configure_window(comp.win, { 214 | number = false, 215 | relativenumber = false, 216 | }) 217 | end 218 | end 219 | end 220 | 221 | if pkg_name == pkg.name then 222 | nuget.fetch_all_package_versions(pkg.name, function(versions_array) 223 | if active_components and active_components.version_list then 224 | active_components.version_list.update(versions_array) 225 | end 226 | end) 227 | end 228 | end) 229 | end 230 | end 231 | end) 232 | end 233 | end, 234 | noremap = true, 235 | silent = true, 236 | }) 237 | 238 | vim.api.nvim_buf_set_keymap(component.buf, "n", "", "", { 239 | callback = function() 240 | if opts.on_tab then 241 | opts.on_tab() 242 | end 243 | end, 244 | noremap = true, 245 | silent = true, 246 | }) 247 | 248 | vim.api.nvim_buf_set_keymap(component.buf, "n", "q", "", { 249 | callback = function() 250 | if opts.on_close then 251 | opts.on_close() 252 | end 253 | end, 254 | noremap = true, 255 | silent = true, 256 | }) 257 | 258 | vim.api.nvim_buf_set_keymap(component.buf, "n", "", "", { 259 | callback = function() 260 | if opts.on_close then 261 | opts.on_close() 262 | end 263 | end, 264 | noremap = true, 265 | silent = true, 266 | }) 267 | end 268 | 269 | component.setup_key_handlers() 270 | 271 | return component 272 | end 273 | 274 | return M 275 | --------------------------------------------------------------------------------