├── .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 |
18 |
19 | ### Package uninstall
20 |
21 |
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 |
--------------------------------------------------------------------------------