a226d673
Default web issue/patch lists to open, matching the nav badge
a73x 2026-08-09 16:50
Commit message
src/server/http/repo/issues.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,10 +1,43 @@ | |||
| 1 | use std::sync::Arc; | 1 | use std::sync::Arc; |
| 2 | 2 | ||
| 3 | use axum::extract::{Path, State}; | 3 | use axum::extract::{Path, Query, State}; |
| 4 | use axum::response::{IntoResponse, Response}; | 4 | use axum::response::{IntoResponse, Response}; |
| 5 | 5 | ||
| 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; | 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; |
| 7 | 7 | ||
| 8 | /// Which issues to include in the list view. Defaults to `Open` so the list | ||
| 9 | /// agrees with the nav badge, which only ever counts open issues. Closed and | ||
| 10 | /// archived issues stay reachable behind the `?filter=` query param. | ||
| 11 | #[derive(Debug, PartialEq, Clone, Copy)] | ||
| 12 | pub enum IssueListFilter { | ||
| 13 | Open, | ||
| 14 | Closed, | ||
| 15 | All, | ||
| 16 | } | ||
| 17 | |||
| 18 | impl IssueListFilter { | ||
| 19 | fn from_query(filter: Option<&str>) -> Self { | ||
| 20 | match filter { | ||
| 21 | Some("closed") => IssueListFilter::Closed, | ||
| 22 | Some("all") => IssueListFilter::All, | ||
| 23 | _ => IssueListFilter::Open, | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | fn as_str(self) -> &'static str { | ||
| 28 | match self { | ||
| 29 | IssueListFilter::Open => "open", | ||
| 30 | IssueListFilter::Closed => "closed", | ||
| 31 | IssueListFilter::All => "all", | ||
| 32 | } | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | #[derive(serde::Deserialize)] | ||
| 37 | pub struct IssuesQuery { | ||
| 38 | pub filter: Option<String>, | ||
| 39 | } | ||
| 40 | |||
| 8 | #[derive(Debug)] | 41 | #[derive(Debug)] |
| 9 | pub struct IssueListItem { | 42 | pub struct IssueListItem { |
| 10 | pub id: String, | 43 | pub id: String, |
| @@ -36,6 +69,7 @@ pub struct IssuesTemplate { | |||
| 36 | pub active_section: String, | 69 | pub active_section: String, |
| 37 | pub open_patches: usize, | 70 | pub open_patches: usize, |
| 38 | pub open_issues: usize, | 71 | pub open_issues: usize, |
| 72 | pub filter: &'static str, | ||
| 39 | pub issues: Vec<IssueListItem>, | 73 | pub issues: Vec<IssueListItem>, |
| 40 | } | 74 | } |
| 41 | 75 | ||
| @@ -50,7 +84,11 @@ pub struct IssueDetailTemplate { | |||
| 50 | pub issue: IssueDetailView, | 84 | pub issue: IssueDetailView, |
| 51 | } | 85 | } |
| 52 | 86 | ||
| 53 | pub async fn issues(Path(repo_name): Path<String>, State(state): State<Arc<AppState>>) -> Response { | 87 | pub async fn issues( |
| 88 | Path(repo_name): Path<String>, | ||
| 89 | Query(query): Query<IssuesQuery>, | ||
| 90 | State(state): State<Arc<AppState>>, | ||
| 91 | ) -> Response { | ||
| 54 | let (_entry, repo) = match open_repo(&state, &repo_name) { | 92 | let (_entry, repo) = match open_repo(&state, &repo_name) { |
| 55 | Ok(r) => r, | 93 | Ok(r) => r, |
| 56 | Err(resp) => return resp, | 94 | Err(resp) => return resp, |
| @@ -58,9 +96,28 @@ pub async fn issues(Path(repo_name): Path<String>, State(state): State<Arc<AppSt | |||
| 58 | 96 | ||
| 59 | let (open_patches, open_issues) = collab_counts(&repo); | 97 | let (open_patches, open_issues) = collab_counts(&repo); |
| 60 | 98 | ||
| 61 | let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default(); | 99 | let filter = IssueListFilter::from_query(query.filter.as_deref()); |
| 100 | |||
| 101 | // "Open" is the common case and matches the badge count, so it's served | ||
| 102 | // from the cheaper non-archived listing. Closed/all need the archived | ||
| 103 | // namespace too, since closing an issue archives it. | ||
| 104 | let filtered_issues = match filter { | ||
| 105 | IssueListFilter::Open => git_collab::state::list_issues(&repo) | ||
| 106 | .unwrap_or_default() | ||
| 107 | .into_iter() | ||
| 108 | .filter(|i| i.status == git_collab::state::IssueStatus::Open) | ||
| 109 | .collect(), | ||
| 110 | IssueListFilter::Closed => git_collab::state::list_issues_with_archived(&repo) | ||
| 111 | .unwrap_or_default() | ||
| 112 | .into_iter() | ||
| 113 | .filter(|i| i.status == git_collab::state::IssueStatus::Closed) | ||
| 114 | .collect(), | ||
| 115 | IssueListFilter::All => { | ||
| 116 | git_collab::state::list_issues_with_archived(&repo).unwrap_or_default() | ||
| 117 | } | ||
| 118 | }; | ||
| 62 | 119 | ||
| 63 | let issues = all_issues | 120 | let issues = filtered_issues |
| 64 | .into_iter() | 121 | .into_iter() |
| 65 | .map(|i| { | 122 | .map(|i| { |
| 66 | let id = i.id.clone(); | 123 | let id = i.id.clone(); |
| @@ -83,6 +140,7 @@ pub async fn issues(Path(repo_name): Path<String>, State(state): State<Arc<AppSt | |||
| 83 | active_section: "issues".to_string(), | 140 | active_section: "issues".to_string(), |
| 84 | open_patches, | 141 | open_patches, |
| 85 | open_issues, | 142 | open_issues, |
| 143 | filter: filter.as_str(), | ||
| 86 | issues, | 144 | issues, |
| 87 | } | 145 | } |
| 88 | .into_response() | 146 | .into_response() |
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,10 +1,48 @@ | |||
| 1 | use std::sync::Arc; | 1 | use std::sync::Arc; |
| 2 | 2 | ||
| 3 | use axum::extract::{Path, State}; | 3 | use axum::extract::{Path, Query, State}; |
| 4 | use axum::response::{IntoResponse, Response}; | 4 | use axum::response::{IntoResponse, Response}; |
| 5 | 5 | ||
| 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; | 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; |
| 7 | 7 | ||
| 8 | /// Which patches to include in the list view. Defaults to `Open` so the list | ||
| 9 | /// agrees with the nav badge, which only ever counts open patches. Merged | ||
| 10 | /// isn't treated as a kind of "closed" here: a rejected/abandoned patch and | ||
| 11 | /// a landed one are different outcomes, so each gets its own filter value | ||
| 12 | /// rather than being folded into a single non-open bucket. | ||
| 13 | #[derive(Debug, PartialEq, Clone, Copy)] | ||
| 14 | pub enum PatchListFilter { | ||
| 15 | Open, | ||
| 16 | Closed, | ||
| 17 | Merged, | ||
| 18 | All, | ||
| 19 | } | ||
| 20 | |||
| 21 | impl PatchListFilter { | ||
| 22 | fn from_query(filter: Option<&str>) -> Self { | ||
| 23 | match filter { | ||
| 24 | Some("closed") => PatchListFilter::Closed, | ||
| 25 | Some("merged") => PatchListFilter::Merged, | ||
| 26 | Some("all") => PatchListFilter::All, | ||
| 27 | _ => PatchListFilter::Open, | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | fn as_str(self) -> &'static str { | ||
| 32 | match self { | ||
| 33 | PatchListFilter::Open => "open", | ||
| 34 | PatchListFilter::Closed => "closed", | ||
| 35 | PatchListFilter::Merged => "merged", | ||
| 36 | PatchListFilter::All => "all", | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | #[derive(serde::Deserialize)] | ||
| 42 | pub struct PatchesQuery { | ||
| 43 | pub filter: Option<String>, | ||
| 44 | } | ||
| 45 | |||
| 8 | #[derive(Debug)] | 46 | #[derive(Debug)] |
| 9 | pub struct PatchListItem { | 47 | pub struct PatchListItem { |
| 10 | pub id: String, | 48 | pub id: String, |
| @@ -24,6 +62,7 @@ pub struct PatchesTemplate { | |||
| 24 | pub active_section: String, | 62 | pub active_section: String, |
| 25 | pub open_patches: usize, | 63 | pub open_patches: usize, |
| 26 | pub open_issues: usize, | 64 | pub open_issues: usize, |
| 65 | pub filter: &'static str, | ||
| 27 | pub patches: Vec<PatchListItem>, | 66 | pub patches: Vec<PatchListItem>, |
| 28 | } | 67 | } |
| 29 | 68 | ||
| @@ -87,6 +126,7 @@ pub struct PatchDetailTemplate { | |||
| 87 | 126 | ||
| 88 | pub async fn patches( | 127 | pub async fn patches( |
| 89 | Path(repo_name): Path<String>, | 128 | Path(repo_name): Path<String>, |
| 129 | Query(query): Query<PatchesQuery>, | ||
| 90 | State(state): State<Arc<AppState>>, | 130 | State(state): State<Arc<AppState>>, |
| 91 | ) -> Response { | 131 | ) -> Response { |
| 92 | let (_entry, repo) = match open_repo(&state, &repo_name) { | 132 | let (_entry, repo) = match open_repo(&state, &repo_name) { |
| @@ -96,9 +136,34 @@ pub async fn patches( | |||
| 96 | 136 | ||
| 97 | let (open_patches, open_issues) = collab_counts(&repo); | 137 | let (open_patches, open_issues) = collab_counts(&repo); |
| 98 | 138 | ||
| 99 | let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default(); | 139 | let filter = PatchListFilter::from_query(query.filter.as_deref()); |
| 140 | |||
| 141 | // "Open" is the common case and matches the badge count, so it's served | ||
| 142 | // from the cheaper non-archived listing. Closed/merged/all need the | ||
| 143 | // archived namespace too, since closing a patch archives it (merging | ||
| 144 | // does not — it's auto-detected from ref state on read). | ||
| 145 | let filtered_patches = match filter { | ||
| 146 | PatchListFilter::Open => git_collab::state::list_patches(&repo) | ||
| 147 | .unwrap_or_default() | ||
| 148 | .into_iter() | ||
| 149 | .filter(|p| p.status == git_collab::state::PatchStatus::Open) | ||
| 150 | .collect(), | ||
| 151 | PatchListFilter::Closed => git_collab::state::list_patches_with_archived(&repo) | ||
| 152 | .unwrap_or_default() | ||
| 153 | .into_iter() | ||
| 154 | .filter(|p| p.status == git_collab::state::PatchStatus::Closed) | ||
| 155 | .collect(), | ||
| 156 | PatchListFilter::Merged => git_collab::state::list_patches_with_archived(&repo) | ||
| 157 | .unwrap_or_default() | ||
| 158 | .into_iter() | ||
| 159 | .filter(|p| p.status == git_collab::state::PatchStatus::Merged) | ||
| 160 | .collect(), | ||
| 161 | PatchListFilter::All => { | ||
| 162 | git_collab::state::list_patches_with_archived(&repo).unwrap_or_default() | ||
| 163 | } | ||
| 164 | }; | ||
| 100 | 165 | ||
| 101 | let patches = all_patches | 166 | let patches = filtered_patches |
| 102 | .into_iter() | 167 | .into_iter() |
| 103 | .map(|p| { | 168 | .map(|p| { |
| 104 | let id = p.id.clone(); | 169 | let id = p.id.clone(); |
| @@ -121,6 +186,7 @@ pub async fn patches( | |||
| 121 | active_section: "patches".to_string(), | 186 | active_section: "patches".to_string(), |
| 122 | open_patches, | 187 | open_patches, |
| 123 | open_issues, | 188 | open_issues, |
| 189 | filter: filter.as_str(), | ||
| 124 | patches, | 190 | patches, |
| 125 | } | 191 | } |
| 126 | .into_response() | 192 | .into_response() |
src/server/http/templates/base.html
| Old | New | ||
|---|---|---|---|
| @@ -27,6 +27,9 @@ | |||
| 27 | .header .site { font-weight: bold; } | 27 | .header .site { font-weight: bold; } |
| 28 | .header nav { display: flex; gap: 12px; } | 28 | .header nav { display: flex; gap: 12px; } |
| 29 | .header nav a.active { font-weight: bold; } | 29 | .header nav a.active { font-weight: bold; } |
| 30 | .filter-bar { color: #666; margin-bottom: 12px; } | ||
| 31 | .filter-bar a { margin-left: 8px; } | ||
| 32 | .filter-bar a.active { font-weight: bold; } | ||
| 30 | .content { padding: 16px; max-width: 1500px; margin: 0 auto; } | 33 | .content { padding: 16px; max-width: 1500px; margin: 0 auto; } |
| 31 | .commit-message { padding: 12px; border: 1px solid #d0d7de; border-radius: 6px; white-space: pre-wrap; } | 34 | .commit-message { padding: 12px; border: 1px solid #d0d7de; border-radius: 6px; white-space: pre-wrap; } |
| 32 | .diff-file-block { border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; } | 35 | .diff-file-block { border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; } |
src/server/http/templates/issues.html
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,12 @@ | |||
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2>Issues</h2> | 6 | <h2>Issues</h2> |
| 7 | <p class="filter-bar"> | ||
| 8 | Showing: | ||
| 9 | <a href="/{{ repo_name }}/issues?filter=open"{% if filter == "open" %} class="active"{% endif %}>open</a> | ||
| 10 | <a href="/{{ repo_name }}/issues?filter=closed"{% if filter == "closed" %} class="active"{% endif %}>closed</a> | ||
| 11 | <a href="/{{ repo_name }}/issues?filter=all"{% if filter == "all" %} class="active"{% endif %}>all</a> | ||
| 12 | </p> | ||
| 7 | {% if issues.is_empty() %} | 13 | {% if issues.is_empty() %} |
| 8 | <p style="color: #666;">No issues.</p> | 14 | <p style="color: #666;">No issues.</p> |
| 9 | {% else %} | 15 | {% else %} |
src/server/http/templates/patches.html
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,13 @@ | |||
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2>Patches</h2> | 6 | <h2>Patches</h2> |
| 7 | <p class="filter-bar"> | ||
| 8 | Showing: | ||
| 9 | <a href="/{{ repo_name }}/patches?filter=open"{% if filter == "open" %} class="active"{% endif %}>open</a> | ||
| 10 | <a href="/{{ repo_name }}/patches?filter=closed"{% if filter == "closed" %} class="active"{% endif %}>closed</a> | ||
| 11 | <a href="/{{ repo_name }}/patches?filter=merged"{% if filter == "merged" %} class="active"{% endif %}>merged</a> | ||
| 12 | <a href="/{{ repo_name }}/patches?filter=all"{% if filter == "all" %} class="active"{% endif %}>all</a> | ||
| 13 | </p> | ||
| 7 | {% if patches.is_empty() %} | 14 | {% if patches.is_empty() %} |
| 8 | <p style="color: #666;">No patches.</p> | 15 | <p style="color: #666;">No patches.</p> |
| 9 | {% else %} | 16 | {% else %} |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -524,6 +524,16 @@ impl TestRepo { | |||
| 524 | .to_string() | 524 | .to_string() |
| 525 | } | 525 | } |
| 526 | 526 | ||
| 527 | /// Close an issue by ID prefix. | ||
| 528 | pub fn issue_close(&self, id: &str) { | ||
| 529 | self.run_ok(&["issue", "close", id]); | ||
| 530 | } | ||
| 531 | |||
| 532 | /// Close a patch by ID prefix. | ||
| 533 | pub fn patch_close(&self, id: &str) { | ||
| 534 | self.run_ok(&["patch", "close", id]); | ||
| 535 | } | ||
| 536 | |||
| 527 | /// Create a patch from a new branch. Returns the 8-char short ID. | 537 | /// Create a patch from a new branch. Returns the 8-char short ID. |
| 528 | pub fn patch_create(&self, title: &str) -> String { | 538 | pub fn patch_create(&self, title: &str) -> String { |
| 529 | // Create a unique branch for this patch | 539 | // Create a unique branch for this patch |
tests/server_behavior_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -189,6 +189,114 @@ fn missing_readme_does_not_break_overview_page() { | |||
| 189 | assert!(overview.body.contains("Open Patches") || overview.body.contains("Recent Commits")); | 189 | assert!(overview.body.contains("Open Patches") || overview.body.contains("Recent Commits")); |
| 190 | } | 190 | } |
| 191 | 191 | ||
| 192 | /// The issue list defaults to open items only, so the count printed on the | ||
| 193 | /// page agrees with the nav badge (which only ever counts open issues). | ||
| 194 | /// Closed issues stay reachable behind an explicit `?filter=` query param | ||
| 195 | /// rather than disappearing outright. | ||
| 196 | #[test] | ||
| 197 | fn issues_list_defaults_to_open_and_offers_closed_filter() { | ||
| 198 | let harness = ServerHarness::new("behavior-issue-filter"); | ||
| 199 | harness.push_head(); | ||
| 200 | |||
| 201 | harness.work_repo().issue_open("Open issue"); | ||
| 202 | let closed_id = harness.work_repo().issue_open("Closed issue"); | ||
| 203 | harness.work_repo().issue_close(&closed_id); | ||
| 204 | harness.push_collab_refs(); | ||
| 205 | |||
| 206 | let repo = harness.repo_name(); | ||
| 207 | |||
| 208 | // Default view: open only, matching the "issues (1)" nav badge. | ||
| 209 | let default_page = harness.get_ok(&format!("/{repo}/issues")); | ||
| 210 | assert!(default_page.body.contains("Open issue")); | ||
| 211 | assert!(!default_page.body.contains("Closed issue")); | ||
| 212 | assert!( | ||
| 213 | default_page.body.contains("issues (1)"), | ||
| 214 | "badge should read issues (1):\n{}", | ||
| 215 | default_page.body | ||
| 216 | ); | ||
| 217 | |||
| 218 | // Explicit filter reaches the closed issue. | ||
| 219 | let closed_page = harness.get_ok(&format!("/{repo}/issues?filter=closed")); | ||
| 220 | assert!(closed_page.body.contains("Closed issue")); | ||
| 221 | assert!(!closed_page.body.contains("Open issue")); | ||
| 222 | |||
| 223 | // "all" reaches both. | ||
| 224 | let all_page = harness.get_ok(&format!("/{repo}/issues?filter=all")); | ||
| 225 | assert!(all_page.body.contains("Open issue")); | ||
| 226 | assert!(all_page.body.contains("Closed issue")); | ||
| 227 | } | ||
| 228 | |||
| 229 | /// The patches list defaults to open items only, matching the nav badge. | ||
| 230 | /// Merged and closed patches are distinct states — merging isn't the same | ||
| 231 | /// kind of terminal state as an explicit close — so each gets its own | ||
| 232 | /// filter value, plus "all" to see everything. | ||
| 233 | #[test] | ||
| 234 | fn patches_list_defaults_to_open_and_offers_closed_and_merged_filters() { | ||
| 235 | let harness = ServerHarness::new("behavior-patch-filter"); | ||
| 236 | harness.push_head(); | ||
| 237 | |||
| 238 | harness.work_repo().patch_create("Open patch"); | ||
| 239 | |||
| 240 | let closed_id = harness.work_repo().patch_create("Closed patch"); | ||
| 241 | harness.work_repo().patch_close(&closed_id); | ||
| 242 | |||
| 243 | harness | ||
| 244 | .work_repo() | ||
| 245 | .git(&["checkout", "-b", "feature-merged"]); | ||
| 246 | harness.work_repo().commit_file( | ||
| 247 | "merged.txt", | ||
| 248 | "content for merged patch", | ||
| 249 | "add file for merged patch", | ||
| 250 | ); | ||
| 251 | harness.work_repo().run_ok(&[ | ||
| 252 | "patch", | ||
| 253 | "create", | ||
| 254 | "-t", | ||
| 255 | "Merged patch", | ||
| 256 | "-B", | ||
| 257 | "feature-merged", | ||
| 258 | ]); | ||
| 259 | harness.work_repo().git(&["checkout", "main"]); | ||
| 260 | harness.work_repo().git(&["merge", "feature-merged"]); | ||
| 261 | |||
| 262 | // Auto-merge detection resolves the patch's source branch on the server | ||
| 263 | // side too (`refs/heads/feature-merged`), so it needs to be pushed | ||
| 264 | // alongside the fast-forwarded main. | ||
| 265 | harness | ||
| 266 | .work_repo() | ||
| 267 | .git(&["push", "origin", "feature-merged"]); | ||
| 268 | harness.push_head(); | ||
| 269 | harness.push_collab_refs(); | ||
| 270 | |||
| 271 | let repo = harness.repo_name(); | ||
| 272 | |||
| 273 | // Default view: open only, matching the "patches (1)" nav badge. | ||
| 274 | let default_page = harness.get_ok(&format!("/{repo}/patches")); | ||
| 275 | assert!(default_page.body.contains("Open patch")); | ||
| 276 | assert!(!default_page.body.contains("Closed patch")); | ||
| 277 | assert!(!default_page.body.contains("Merged patch")); | ||
| 278 | assert!( | ||
| 279 | default_page.body.contains("patches (1)"), | ||
| 280 | "badge should read patches (1):\n{}", | ||
| 281 | default_page.body | ||
| 282 | ); | ||
| 283 | |||
| 284 | let closed_page = harness.get_ok(&format!("/{repo}/patches?filter=closed")); | ||
| 285 | assert!(closed_page.body.contains("Closed patch")); | ||
| 286 | assert!(!closed_page.body.contains("Open patch")); | ||
| 287 | assert!(!closed_page.body.contains("Merged patch")); | ||
| 288 | |||
| 289 | let merged_page = harness.get_ok(&format!("/{repo}/patches?filter=merged")); | ||
| 290 | assert!(merged_page.body.contains("Merged patch")); | ||
| 291 | assert!(!merged_page.body.contains("Open patch")); | ||
| 292 | assert!(!merged_page.body.contains("Closed patch")); | ||
| 293 | |||
| 294 | let all_page = harness.get_ok(&format!("/{repo}/patches?filter=all")); | ||
| 295 | assert!(all_page.body.contains("Open patch")); | ||
| 296 | assert!(all_page.body.contains("Closed patch")); | ||
| 297 | assert!(all_page.body.contains("Merged patch")); | ||
| 298 | } | ||
| 299 | |||
| 192 | /// Every `onchange="..."` attribute value in `body`. | 300 | /// Every `onchange="..."` attribute value in `body`. |
| 193 | /// | 301 | /// |
| 194 | /// An inline event-handler attribute is HTML-decoded *before* its contents are | 302 | /// An inline event-handler attribute is HTML-decoded *before* its contents are |