| 1 | using AgentForge.Data; |
| 2 | using AgentForge.Data.Entities; |
| 3 | using AgentForge.Models; |
| 4 | using AgentForge.Services; |
| 5 | |
| 6 | namespace AgentForge.Plugin.View.Components.Search; |
| 7 | |
| 8 | internal static class ForgeViewSearchPage |
| 9 | { |
| 10 | internal static string Render( |
| 11 | string repoName, |
| 12 | string query, |
| 13 | ForgeRepoEntity repo, |
| 14 | ForgeRepository repository, |
| 15 | GitRepoService git) |
| 16 | { |
| 17 | var trimmed = query.Trim(); |
| 18 | var form = ForgeHtml.Form( |
| 19 | "get", |
| 20 | $"/view/repos/{ForgeRepoNames.ToUrlPath(repoName)}/search", |
| 21 | "search-page-form", |
| 22 | ForgeHtml.Input(new ForgeInputSpec("q", Type: "search", Value: trimmed, Placeholder: "Issues, merge requests, files…")), |
| 23 | ForgeHtml.Button("submit", "Search")); |
| 24 | |
| 25 | if (trimmed.Length == 0) |
| 26 | { |
| 27 | return ForgeHtml.Fragment( |
| 28 | ForgeHtml.H1("Search"), |
| 29 | form, |
| 30 | ForgeHtml.P("meta", ForgeHtml.Text("Enter a query to search this repository."))); |
| 31 | } |
| 32 | |
| 33 | var issues = SearchIssues(repository, repo.Id, trimmed); |
| 34 | var mrs = SearchMergeRequests(repository, repo.Id, trimmed); |
| 35 | var files = SearchFiles(git, repo.Name, git.GetDefaultBranch(repo.Name), trimmed); |
| 36 | |
| 37 | if (issues.Count == 0 && mrs.Count == 0 && files.Count == 0) |
| 38 | { |
| 39 | return ForgeHtml.Fragment( |
| 40 | ForgeHtml.H1("Search"), |
| 41 | form, |
| 42 | ForgeHtml.P("meta", |
| 43 | ForgeHtml.Text("No results for "), |
| 44 | ForgeHtml.Strong(trimmed), |
| 45 | ForgeHtml.Text("."))); |
| 46 | } |
| 47 | |
| 48 | var sections = new List<string> { ForgeHtml.H1("Search"), form }; |
| 49 | |
| 50 | if (issues.Count > 0) |
| 51 | sections.Add(RenderIssueSection(repoName, issues)); |
| 52 | if (mrs.Count > 0) |
| 53 | sections.Add(RenderMergeRequestSection(repoName, mrs)); |
| 54 | if (files.Count > 0) |
| 55 | sections.Add(RenderFileSection(repoName, git.GetDefaultBranch(repo.Name) ?? "main", files)); |
| 56 | |
| 57 | return ForgeHtml.Fragment([.. sections]); |
| 58 | } |
| 59 | |
| 60 | private static string RenderIssueSection(string repoName, IReadOnlyList<ForgeIssueEntity> issues) |
| 61 | { |
| 62 | var items = issues.Select(issue => |
| 63 | { |
| 64 | var href = $"/view/repos/{ForgeRepoNames.ToUrlPath(repoName)}/issues/{issue.Number}"; |
| 65 | return ForgeHtml.Li(null, |
| 66 | ForgeHtml.A(href, $"#{issue.Number}"), |
| 67 | ForgeHtml.Text(" "), |
| 68 | ForgeHtml.Text(issue.Title), |
| 69 | ForgeHtml.Text(" "), |
| 70 | ForgeHtml.Badge(issue.Status.ToSlug())); |
| 71 | }).ToArray(); |
| 72 | |
| 73 | return ForgeHtml.Fragment( |
| 74 | ForgeHtml.H2("Issues"), |
| 75 | ForgeHtml.Ul("plain", items)); |
| 76 | } |
| 77 | |
| 78 | private static string RenderMergeRequestSection(string repoName, IReadOnlyList<ForgeMergeRequestEntity> mergeRequests) |
| 79 | { |
| 80 | var items = mergeRequests.Select(mr => |
| 81 | { |
| 82 | var href = $"/view/repos/{ForgeRepoNames.ToUrlPath(repoName)}/merge-requests/{mr.Number}"; |
| 83 | return ForgeHtml.Li(null, |
| 84 | ForgeHtml.A(href, $"!{mr.Number}"), |
| 85 | ForgeHtml.Text(" "), |
| 86 | ForgeHtml.Text(mr.Title), |
| 87 | ForgeHtml.Text(" "), |
| 88 | ForgeHtml.Badge(mr.Status.ToSlug())); |
| 89 | }).ToArray(); |
| 90 | |
| 91 | return ForgeHtml.Fragment( |
| 92 | ForgeHtml.H2("Merge requests"), |
| 93 | ForgeHtml.Ul("plain", items)); |
| 94 | } |
| 95 | |
| 96 | private static string RenderFileSection(string repoName, string branch, IReadOnlyList<string> files) |
| 97 | { |
| 98 | var items = files.Select(path => |
| 99 | { |
| 100 | var href = $"/view/repos/{ForgeRepoNames.ToUrlPath(repoName)}/blob?branch={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(path)}"; |
| 101 | return ForgeHtml.Li(null, ForgeHtml.A(href, path)); |
| 102 | }).ToArray(); |
| 103 | |
| 104 | return ForgeHtml.Fragment( |
| 105 | ForgeHtml.H2("Files"), |
| 106 | ForgeHtml.Ul("plain", items)); |
| 107 | } |
| 108 | |
| 109 | private static List<ForgeIssueEntity> SearchIssues(ForgeRepository repository, string repoId, string query) => |
| 110 | repository.ListIssues(repoId) |
| 111 | .Where(issue => Matches(issue.Title, query) || Matches(issue.Body, query)) |
| 112 | .OrderByDescending(issue => issue.Number) |
| 113 | .Take(20) |
| 114 | .ToList(); |
| 115 | |
| 116 | private static List<ForgeMergeRequestEntity> SearchMergeRequests(ForgeRepository repository, string repoId, string query) => |
| 117 | repository.ListMergeRequests(repoId) |
| 118 | .Where(mr => Matches(mr.Title, query)) |
| 119 | .OrderByDescending(mr => mr.Number) |
| 120 | .Take(20) |
| 121 | .ToList(); |
| 122 | |
| 123 | private static List<string> SearchFiles(GitRepoService git, string repoName, string? branch, string query) |
| 124 | { |
| 125 | if (string.IsNullOrWhiteSpace(branch) || !git.BareRepoExists(repoName)) |
| 126 | return []; |
| 127 | |
| 128 | var results = new List<string>(); |
| 129 | WalkFiles(git, repoName, branch, path: null, query, results, limit: 25); |
| 130 | return results; |
| 131 | } |
| 132 | |
| 133 | private static void WalkFiles( |
| 134 | GitRepoService git, |
| 135 | string repoName, |
| 136 | string branch, |
| 137 | string? path, |
| 138 | string query, |
| 139 | List<string> results, |
| 140 | int limit) |
| 141 | { |
| 142 | if (results.Count >= limit) |
| 143 | return; |
| 144 | |
| 145 | IReadOnlyList<GitRepoService.GitTreeItem> entries; |
| 146 | try |
| 147 | { |
| 148 | entries = git.ListDirectory(repoName, branch, path); |
| 149 | } |
| 150 | catch |
| 151 | { |
| 152 | return; |
| 153 | } |
| 154 | |
| 155 | foreach (var entry in entries) |
| 156 | { |
| 157 | if (results.Count >= limit) |
| 158 | return; |
| 159 | |
| 160 | if (entry.IsDirectory) |
| 161 | { |
| 162 | WalkFiles(git, repoName, branch, entry.Path, query, results, limit); |
| 163 | continue; |
| 164 | } |
| 165 | |
| 166 | if (entry.Path.Contains(query, StringComparison.OrdinalIgnoreCase)) |
| 167 | results.Add(entry.Path); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | private static bool Matches(string? value, string query) => |
| 172 | !string.IsNullOrWhiteSpace(value) |
| 173 | && value.Contains(query, StringComparison.OrdinalIgnoreCase); |
| 174 | } |
| 175 | |