OXYGENHUB

Lists: filters, sort orders, pages, and counts on the tabs 1ac84c62ba53

The Proposals and Issues tabs show how many are open. The issue list
gains author: beside label:, assignee: and milestone:, and sorts by
newest (pinned first), oldest, recently updated or most comments. The
proposal list gains a filter (author:, label:, target: and words), its
labels, and sorts by recently updated, newest or oldest. Both page 25
at a time with links that keep the filter and sort.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 09:49 UTC
Signature
✔ signed by Andy McCall
Hash
1ac84c62ba5339fce067c2888f3112f749b2f357ec6f3dd32567a970e2fbeee7
Depends on
1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 93be9ce4653e d23464c0ac60 de505df5cf8d
Depended on by
166f6e9bf4f1 0ee5c3fb32eb a9f1c0bf403a 4a68f1064b08 16ee5d19d220 5d90e0e045e2 d0fbea2eb46e 9008bf6c2b8c e1ec9fbf538e 756fe44a0f4d 42fa95540468 64caed18236a
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
42 atoms, 1 tree operation, 12 files

Browse the tree at this change

added templates/pager.html +7 −0
1 +
{% if pager.pages > 1 %}
2 +
<nav class="pager small" aria-label="Pages">
3 +
  {% if pager.page > 1 %}<a class="go" href="{{ pager.url(pager.page - 1) }}" rel="prev">← newer</a>{% endif %}
4 +
  <span class="mute">page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} in all</span>
5 +
  {% if pager.page < pager.pages %}<a class="go" href="{{ pager.url(pager.page + 1) }}" rel="next">older →</a>{% endif %}
6 +
</nav>
7 +
{% endif %}
modified crates/hub/src/db/issues.rs +14 −0
…
259 unchanged lines
260 260
}
261 261
262 262
/// Labels of every issue of a repository, for the list page.
263 +
/// Every proposal's labels in a repository, for filtering the list.
264 +
pub async fn labels_by_proposal(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> {
265 +
    let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as(
266 +
        "SELECT pl.proposal_id, l.id, l.repo_id, l.name, l.colour, l.description FROM proposal_labels pl JOIN labels l ON l.id = pl.label_id JOIN proposals p ON p.id = pl.proposal_id WHERE p.repo_id = ? ORDER BY l.name",
267 +
    )
268 +
    .bind(repo_id)
269 +
    .fetch_all(pool)
270 +
    .await?;
271 +
    Ok(rows
272 +
        .into_iter()
273 +
        .map(|(proposal_id, id, repo_id, name, colour, description)| (proposal_id, Label { id, repo_id, name, colour, description }))
274 +
        .collect())
275 +
}
276 +
263 277
pub async fn labels_by_issue(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> {
264 278
    let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as(
265 279
        "SELECT il.issue_id, l.id, l.repo_id, l.name, l.colour, l.description FROM issue_labels il JOIN labels l ON l.id = il.label_id JOIN issues i ON i.id = il.issue_id WHERE i.repo_id = ? ORDER BY l.name",
…
107 unchanged lines
modified crates/hub/src/db/repos.rs +16 −1
…
23 unchanged lines
24 24
    /// Space-separated topics (migration 0010).
25 25
    #[sqlx(default)]
26 26
    pub topics: String,
27 +
    /// Counts for the tabs; filled by `get` only.
28 +
    #[sqlx(default)]
29 +
    pub open_issues: i64,
30 +
    #[sqlx(default)]
31 +
    pub open_proposals: i64,
27 32
}
28 33
29 34
impl RepoRow {
…
48 unchanged lines
78 83
    sqlx::query_as("SELECT * FROM repositories WHERE owner = ? ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await
79 84
}
80 85
86 +
/// One repository, with its open issue and proposal counts for the tabs.
81 87
pub async fn get(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> {
82 -
    sqlx::query_as("SELECT * FROM repositories WHERE owner = ? AND name = ?").bind(owner).bind(name).fetch_optional(pool).await
88 +
    sqlx::query_as(
89 +
        "SELECT r.*, \
90 +
           (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.state = 'open') AS open_issues, \
91 +
           (SELECT COUNT(*) FROM proposals p WHERE p.repo_id = r.id AND p.state IN ('open', 'draft')) AS open_proposals \
92 +
         FROM repositories r WHERE r.owner = ? AND r.name = ?",
93 +
    )
94 +
    .bind(owner)
95 +
    .bind(name)
96 +
    .fetch_optional(pool)
97 +
    .await
83 98
}
84 99
85 100
pub async fn create(pool: &Pool, owner: &str, name: &str, description: &str, visibility: Visibility) -> sqlx::Result<RepoRow> {
…
164 unchanged lines
modified crates/hub/src/web/issues.rs +39 −2
…
46 unchanged lines
47 47
    state: String,
48 48
    q: String,
49 49
    counts: (usize, usize),
50 +
    sort: String,
51 +
    pager: super::Pager,
50 52
}
51 53
52 54
#[derive(Deserialize)]
53 55
pub struct ListQuery {
54 56
    state: Option<String>,
55 57
    q: Option<String>,
58 +
    sort: Option<String>,
59 +
    page: Option<usize>,
56 60
}
57 61
62 +
/// The list's sort orders: `newest` (pinned first, the default),
63 +
/// `oldest`, `updated`, `comments`.
64 +
pub const SORTS: &[(&str, &str)] =
65 +
    &[("newest", "newest"), ("oldest", "oldest"), ("updated", "recently updated"), ("comments", "most comments")];
66 +
58 67
/// `label:bug assignee:andy milestone:v1 state:closed words…`
59 68
struct Filter {
60 69
    state: String,
61 70
    labels: Vec<String>,
62 71
    assignees: Vec<String>,
72 +
    authors: Vec<String>,
63 73
    milestone: Option<String>,
64 74
    words: Vec<String>,
65 75
}
66 76
67 77
fn parse_filter(state: Option<String>, q: &str) -> Filter {
68 -
    let mut f = Filter { state: state.unwrap_or_else(|| "open".into()), labels: vec![], assignees: vec![], milestone: None, words: vec![] };
78 +
    let mut f = Filter {
79 +
        state: state.unwrap_or_else(|| "open".into()),
80 +
        labels: vec![],
81 +
        assignees: vec![],
82 +
        authors: vec![],
83 +
        milestone: None,
84 +
        words: vec![],
85 +
    };
69 86
    for tok in q.split_whitespace() {
70 87
        match tok.split_once(':') {
71 88
            Some(("label", v)) => f.labels.push(v.to_lowercase()),
72 89
            Some(("assignee", v)) => f.assignees.push(v.to_lowercase()),
90 +
            Some(("author", v)) => f.authors.push(v.to_lowercase()),
73 91
            Some(("milestone", v)) => f.milestone = Some(v.to_lowercase()),
74 92
            Some(("state", v)) => f.state = v.to_string(),
75 93
            _ => f.words.push(tok.to_lowercase()),
…
33 unchanged lines
109 127
        let keep = (f.state == "all" || i.state == f.state)
110 128
            && f.labels.iter().all(|l| labels.iter().any(|x| x.name.to_lowercase() == *l))
111 129
            && f.assignees.iter().all(|a| assignees.iter().any(|x| x.to_lowercase() == *a))
130 +
            && (f.authors.is_empty() || f.authors.contains(&i.author.to_lowercase()))
112 131
            && f.milestone.as_ref().is_none_or(|m| milestone.as_ref().is_some_and(|x| x.to_lowercase() == *m))
113 132
            && f.words.iter().all(|w| i.title.to_lowercase().contains(w) || i.body.to_lowercase().contains(w));
114 133
        if keep {
…
5 unchanged lines
120 139
            rows.push(IssueLine { issue: i, labels, assignees, milestone, comments });
121 140
        }
122 141
    }
123 -
    Ok(Page(IssuesPage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), rows, state: f.state, q: qtext, counts }))
142 +
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "newest".into());
143 +
    match sort.as_str() {
144 +
        "oldest" => rows.sort_by_key(|r| r.issue.number),
145 +
        "updated" => rows.sort_by_key(|r| std::cmp::Reverse(r.issue.updated_ms)),
146 +
        "comments" => rows.sort_by_key(|r| (std::cmp::Reverse(r.comments), std::cmp::Reverse(r.issue.number))),
147 +
        _ => {}
148 +
    }
149 +
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &f.state), ("q", &qtext), ("sort", &sort)]);
150 +
    let rows = pager.slice(rows);
151 +
    Ok(Page(IssuesPage {
152 +
        shell,
153 +
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
154 +
        rows,
155 +
        state: f.state,
156 +
        q: qtext,
157 +
        counts,
158 +
        sort,
159 +
        pager,
160 +
    }))
124 161
}
125 162
126 163
// ------------------------------------------------------------------- new
…
785 unchanged lines
modified crates/hub/src/web/mod.rs +56 −0
…
282 unchanged lines
283 283
    pub clone_url: String,
284 284
    /// The viewer's role, for showing settings and write actions.
285 285
    pub role: Option<Role>,
286 +
    /// Open issues and proposals, shown on their tabs.
287 +
    pub open_issues: i64,
288 +
    pub open_proposals: i64,
286 289
}
287 290
288 291
impl RepoShell {
…
8 unchanged lines
297 300
            tab,
298 301
            clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name),
299 302
            role: None,
303 +
            open_issues: row.open_issues,
304 +
            open_proposals: row.open_proposals,
300 305
        }
301 306
    }
302 307
…
12 unchanged lines
315 320
}
316 321
317 322
/// Render a template as an HTML response.
323 +
/// One page of a long list: which page, how many, and links that keep
324 +
/// the rest of the query.
325 +
#[derive(Clone, Debug)]
326 +
pub struct Pager {
327 +
    pub page: usize,
328 +
    pub pages: usize,
329 +
    pub total: usize,
330 +
    /// The query without `page`, each pair encoded, joined with `&`.
331 +
    query: String,
332 +
}
333 +
334 +
impl Pager {
335 +
    pub const PER_PAGE: usize = 25;
336 +
337 +
    /// `page` counts from 1; out-of-range pages clamp to the last.
338 +
    pub fn new(total: usize, page: Option<usize>, query: &[(&str, &str)]) -> Pager {
339 +
        let pages = total.div_ceil(Self::PER_PAGE).max(1);
340 +
        let page = page.unwrap_or(1).clamp(1, pages);
341 +
        let query = query
342 +
            .iter()
343 +
            .filter(|(_, v)| !v.is_empty())
344 +
            .map(|(k, v)| format!("{k}={}", settings::urlencode(v).replace('/', "%2F")))
345 +
            .collect::<Vec<_>>()
346 +
            .join("&");
347 +
        Pager { page, pages, total, query }
348 +
    }
349 +
350 +
    /// This page's items.
351 +
    pub fn slice<T>(&self, all: Vec<T>) -> Vec<T> {
352 +
        all.into_iter().skip((self.page - 1) * Self::PER_PAGE).take(Self::PER_PAGE).collect()
353 +
    }
354 +
355 +
    pub fn url(&self, page: usize) -> String {
356 +
        if self.query.is_empty() {
357 +
            format!("?page={page}")
358 +
        } else {
359 +
            format!("?{}&page={page}", self.query)
360 +
        }
361 +
    }
362 +
363 +
    /// The same query with another sort, back on page one.
364 +
    pub fn sorted(&self, sort: &str) -> String {
365 +
        let rest: Vec<&str> = self.query.split('&').filter(|p| !p.is_empty() && !p.starts_with("sort=")).collect();
366 +
        let mut q = rest.join("&");
367 +
        if !q.is_empty() {
368 +
            q.push('&');
369 +
        }
370 +
        format!("?{q}sort={sort}")
371 +
    }
372 +
}
373 +
318 374
pub struct Page<T: Template>(pub T);
319 375
320 376
impl<T: Template> IntoResponse for Page<T> {
…
176 unchanged lines
modified crates/hub/src/web/proposals.rs +56 −2
…
29 unchanged lines
30 30
    filter: String,
31 31
    counts: (usize, usize, usize),
32 32
    bookmarks: Vec<String>,
33 +
    q: String,
34 +
    sort: String,
35 +
    pager: super::Pager,
36 +
    labels: std::collections::HashMap<i64, Vec<db::issues::Label>>,
33 37
}
34 38
39 +
impl ProposalsPage {
40 +
    fn labels_of(&self, id: &i64) -> &[db::issues::Label] {
41 +
        self.labels.get(id).map(Vec::as_slice).unwrap_or(&[])
42 +
    }
43 +
}
44 +
35 45
#[derive(Deserialize)]
36 46
pub struct ListQuery {
37 47
    state: Option<String>,
48 +
    q: Option<String>,
49 +
    sort: Option<String>,
50 +
    page: Option<usize>,
38 51
}
39 52
53 +
/// The list's sort orders.
54 +
pub const SORTS: &[(&str, &str)] = &[("updated", "recently updated"), ("newest", "newest"), ("oldest", "oldest")];
55 +
40 56
pub async fn list(
41 57
    State(app): State<Shared>,
42 58
    viewer: Viewer,
…
9 unchanged lines
52 68
        all.iter().filter(|p| p.state == "closed").count(),
53 69
    );
54 70
    let filter = q.state.unwrap_or_else(|| "open".into());
55 -
    let rows = all
71 +
    let mut labels: std::collections::HashMap<i64, Vec<db::issues::Label>> = std::collections::HashMap::new();
72 +
    for (id, l) in db::issues::labels_by_proposal(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
73 +
        labels.entry(id).or_default().push(l);
74 +
    }
75 +
    // `author:bob label:ui target:main words…`
76 +
    let qtext = q.q.clone().unwrap_or_default();
77 +
    let (mut authors, mut wanted, mut targets, mut words) = (vec![], vec![], vec![], vec![]);
78 +
    for tok in qtext.split_whitespace() {
79 +
        match tok.split_once(':') {
80 +
            Some(("author", v)) => authors.push(v.to_lowercase()),
81 +
            Some(("label", v)) => wanted.push(v.to_lowercase()),
82 +
            Some(("target", v)) => targets.push(v.to_string()),
83 +
            _ => words.push(tok.to_lowercase()),
84 +
        }
85 +
    }
86 +
    let mut rows: Vec<pdb::ProposalRow> = all
56 87
        .into_iter()
57 88
        .filter(|p| match filter.as_str() {
58 89
            "open" => p.is_open(),
…
1 unchanged lines
60 91
            "closed" => p.state == "closed",
61 92
            _ => true,
62 93
        })
94 +
        .filter(|p| authors.is_empty() || authors.contains(&p.author.to_lowercase()))
95 +
        .filter(|p| targets.is_empty() || targets.contains(&p.target))
96 +
        .filter(|p| wanted.iter().all(|w| labels.get(&p.id).is_some_and(|ls| ls.iter().any(|l| l.name.to_lowercase() == *w))))
97 +
        .filter(|p| words.iter().all(|w| p.title.to_lowercase().contains(w) || p.body.to_lowercase().contains(w)))
63 98
        .collect();
99 +
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "updated".into());
100 +
    match sort.as_str() {
101 +
        "newest" => rows.sort_by_key(|p| std::cmp::Reverse(p.number)),
102 +
        "oldest" => rows.sort_by_key(|p| p.number),
103 +
        _ => rows.sort_by_key(|p| std::cmp::Reverse(p.updated_ms)),
104 +
    }
105 +
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &filter), ("q", &qtext), ("sort", &sort)]);
106 +
    let rows = pager.slice(rows);
64 107
    let bookmarks = app
65 108
        .repos
66 109
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
67 110
        .await
68 111
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
69 -
    Ok(Page(ProposalsPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), rows, filter, counts, bookmarks }))
112 +
    Ok(Page(ProposalsPage {
113 +
        shell,
114 +
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
115 +
        rows,
116 +
        filter,
117 +
        counts,
118 +
        bookmarks,
119 +
        q: qtext,
120 +
        sort,
121 +
        pager,
122 +
        labels,
123 +
    }))
70 124
}
71 125
72 126
// ------------------------------------------------------------------- new
…
1040 unchanged lines
modified crates/hub/tests/issues.rs +68 −0
…
587 unchanged lines
588 588
    let by_topic = get(app, "/explore?topic=6502", None).await.body;
589 589
    assert!(by_topic.contains("/andy/gamma\"") && !by_topic.contains("/andy/beta\""), "{by_topic}");
590 590
}
591 +
592 +
#[tokio::test(flavor = "multi_thread")]
593 +
async fn lists_filter_sort_page_and_count() {
594 +
    let h = hub().await;
595 +
    let app = &h.app;
596 +
    let andy = register(app, "andy").await;
597 +
    let _bob = register(app, "bob").await;
598 +
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
599 +
    let repo = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
600 +
    for n in 0..30 {
601 +
        let author = if n % 3 == 0 { "bob" } else { "andy" };
602 +
        db::issues::create(
603 +
            &app.db,
604 +
            db::issues::NewIssue { repo_id: repo.id, title: &format!("Issue number {n}"), body: "", author_id: None, author },
605 +
        )
606 +
        .await
607 +
        .unwrap();
608 +
    }
609 +
    let ui = db::issues::upsert_label(&app.db, repo.id, "ui", "#00f0ff", "").await.unwrap();
610 +
    for (title, author, target) in [("Faster menus", "bob", "main"), ("New theme", "andy", "main"), ("Release prep", "andy", "stable")] {
611 +
        let p = db::proposals::create(
612 +
            &app.db,
613 +
            db::proposals::NewProposal {
614 +
                repo_id: repo.id,
615 +
                title,
616 +
                body: "",
617 +
                author_id: None,
618 +
                author,
619 +
                draft: false,
620 +
                target,
621 +
                source_kind: "changes",
622 +
                source_repo_id: None,
623 +
                source_bookmark: "",
624 +
                source_changes: "",
625 +
            },
626 +
        )
627 +
        .await
628 +
        .unwrap();
629 +
        if title == "New theme" {
630 +
            db::issues::set_proposal_label(&app.db, p.id, ui.id, true).await.unwrap();
631 +
        }
632 +
    }
633 +
634 +
    // The tabs count what is open.
635 +
    let page = get(app, "/andy/demo/issues", Some(&andy)).await;
636 +
    assert!(page.body.contains(r#"Issues <span class="count-chip">30</span>"#), "{}", page.body);
637 +
    assert!(page.body.contains(r#"Proposals <span class="count-chip">3</span>"#));
638 +
639 +
    // Pages of 25, newest first; the second page holds the rest.
640 +
    let rows = |b: &str| b.matches("/andy/demo/issue/").count();
641 +
    assert_eq!(rows(&page.body), 25);
642 +
    assert!(page.body.contains("page 1 of 2") && page.body.contains("page=2"), "a pager");
643 +
    let second = get(app, "/andy/demo/issues?page=2", Some(&andy)).await;
644 +
    assert_eq!(rows(&second.body), 5);
645 +
    assert!(second.body.contains("Issue number 0<"), "the oldest are last");
646 +
    let oldest = get(app, "/andy/demo/issues?sort=oldest", Some(&andy)).await;
647 +
    assert!(oldest.body.find("Issue number 0<").unwrap() < oldest.body.find("Issue number 1<").unwrap());
648 +
    let bobs = get(app, "/andy/demo/issues?q=author:bob", Some(&andy)).await;
649 +
    assert_eq!(rows(&bobs.body), 10, "{}", bobs.body);
650 +
651 +
    // Proposals: by author, label and target.
652 +
    let by_bob = get(app, "/andy/demo/proposals?q=author:bob", Some(&andy)).await.body;
653 +
    assert!(by_bob.contains("Faster menus") && !by_bob.contains("New theme"));
654 +
    let ui_only = get(app, "/andy/demo/proposals?q=label:ui", Some(&andy)).await.body;
655 +
    assert!(ui_only.contains("New theme") && !ui_only.contains("Faster menus") && ui_only.contains(r#"class="label""#));
656 +
    let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body;
657 +
    assert!(stable.contains("Release prep") && !stable.contains("New theme"));
658 +
}
modified docs/spec.md +2 −2
…
236 unchanged lines
237 237
| `/{owner}/{name}/compare/{a}...{b}` | Changes in `b` not in `a`, and the diff between the two states |
238 238
| `/{owner}/{name}/bookmarks` | Bookmarks with heads, ahead/behind the default, last update, attestation status |
239 239
| `/{owner}/{name}/conflicts` | Every bookmark whose state carries conflicts, each with its regions (§6.10) |
240 -
| `/{owner}/{name}/proposals`, `/proposal/{n}` | §7 |
241 -
| `/{owner}/{name}/issues`, `/issue/{n}` | §8 |
240 +
| `/{owner}/{name}/proposals`, `/proposal/{n}` | §7. The list filters by state and by `q` (`author:`, `label:`, `target:` and words), sorts by recently updated (default), newest or oldest, and pages 25 at a time |
241 +
| `/{owner}/{name}/issues`, `/issue/{n}` | §8. The list filters by state and by `q` (`author:`, `label:`, `assignee:`, `milestone:` and words), sorts by newest (pinned first, default), oldest, recently updated or most comments, and pages 25 at a time. The Proposals and Issues tabs show how many are open |
242 242
| `/{owner}/{name}/releases`, `/tags` | §9 |
243 243
| `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) |
244 244
| `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) |
…
305 unchanged lines
modified templates/issues.html +3 −1
…
5 unchanged lines
6 6
{% block content %}
7 7
<div class="head"><h1>Issues <small>{{ counts.0 }} open · {{ counts.1 }} closed</small></h1></div>
8 8
<div class="bar">
9 -
  <form class="find" method="get" action="{{ repo.url }}/issues"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, label:bug, assignee:andy, milestone:v1, state:closed"></form>
9 +
  <form class="find" method="get" action="{{ repo.url }}/issues"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, author:bob, label:bug, assignee:andy, milestone:v1, state:closed"><input type="hidden" name="state" value="{{ state }}"><input type="hidden" name="sort" value="{{ sort }}"></form>
10 10
  <span class="modes">
11 11
    <a href="?state=open"{% if state == "open" %} class="on"{% endif %}>open</a>
12 12
    <a href="?state=closed"{% if state == "closed" %} class="on"{% endif %}>closed</a>
13 13
    <a href="?state=all"{% if state == "all" %} class="on"{% endif %}>all</a>
14 14
  </span>
15 +
  <span class="modes small" aria-label="Sort">{% for (k, label) in crate::web::issues::SORTS %}<a href="{{ pager.sorted(k) }}"{% if sort == *k %} class="on"{% endif %}>{{ label }}</a>{% endfor %}</span>
15 16
  <a class="go small" href="{{ repo.url }}/labels">labels</a>
16 17
  <a class="go small" href="{{ repo.url }}/milestones">milestones</a>
17 18
  {% if shell.user.is_some() %}<a class="go small" href="{{ repo.url }}/issues/new">new issue</a>{% endif %}
…
12 unchanged lines
30 31
  </li>
31 32
{% endfor %}
32 33
</ul>
34 +
{% include "pager.html" %}
33 35
{% endif %}
34 36
{% endblock %}
modified templates/proposals.html +9 −3
…
6 unchanged lines
7 7
<div class="head"><h1>Proposals <small>changes offered for a bookmark; they never go stale</small></h1></div>
8 8
<div class="bar">
9 9
  <span class="modes">
10 -
    <a href="?state=open"{% if filter == "open" %} class="on"{% endif %}>{{ counts.0 }} open</a>
11 -
    <a href="?state=landed"{% if filter == "landed" %} class="on"{% endif %}>{{ counts.1 }} landed</a>
12 -
    <a href="?state=closed"{% if filter == "closed" %} class="on"{% endif %}>{{ counts.2 }} closed</a>
10 +
    <a href="?state=open&amp;q={{ q }}"{% if filter == "open" %} class="on"{% endif %}>{{ counts.0 }} open</a>
11 +
    <a href="?state=landed&amp;q={{ q }}"{% if filter == "landed" %} class="on"{% endif %}>{{ counts.1 }} landed</a>
12 +
    <a href="?state=closed&amp;q={{ q }}"{% if filter == "closed" %} class="on"{% endif %}>{{ counts.2 }} closed</a>
13 13
  </span>
14 14
  {% if shell.user.is_some() %}<a class="go small" href="{{ repo.url }}/proposals/new">new proposal</a>{% endif %}
15 15
</div>
16 +
<div class="bar">
17 +
  <form class="find" method="get" action="{{ repo.url }}/proposals"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, author:bob, label:ui, target:main"><input type="hidden" name="state" value="{{ filter }}"><input type="hidden" name="sort" value="{{ sort }}"></form>
18 +
  <span class="modes small" aria-label="Sort">{% for (k, label) in crate::web::proposals::SORTS %}<a href="{{ pager.sorted(k) }}"{% if sort == *k %} class="on"{% endif %}>{{ label }}</a>{% endfor %}</span>
19 +
</div>
16 20
{% if rows.is_empty() %}
17 21
<p class="empty">No {{ filter }} proposals. Push a bookmark, then <a href="{{ repo.url }}/proposals/new">propose it</a> for {{ repo.default_bookmark }}.{% if bookmarks.len() > 1 %} Bookmarks here: {% for b in bookmarks %}<a href="{{ repo.url }}/proposals/new?source={{ b }}">{{ b }}</a>{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %}</p>
18 22
{% else %}
…
4 unchanged lines
23 27
      <a class="name" href="{{ repo.url }}/proposal/{{ p.number }}"><span class="mute">#{{ p.number }}</span> <strong>{{ p.title }}</strong></a>
24 28
      {% if p.state == "draft" %}<span class="tag">draft</span>{% endif %}
25 29
      {% if p.conflicted %}<span class="tag bad">conflicts</span>{% endif %}
30 +
      {% for l in labels_of(p.id) %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span>{% endfor %}
26 31
      <p class="small">{{ p.author }} wants <span class="lbl lbl-bm">{{ p.source_bookmark }}</span> into <span class="lbl lbl-bm">{{ p.target }}</span> · {{ p.projected_changes().len()|plural("change", "changes") }}</p>
27 32
    </div>
28 33
    <div class="right small">updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></div>
29 34
  </li>
30 35
{% endfor %}
31 36
</ul>
37 +
{% include "pager.html" %}
32 38
{% endif %}
33 39
{% endblock %}
modified templates/repo_tabs.html +2 −2
…
3 unchanged lines
4 4
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.visibility != "public" %} <span class="tag">{{ repo.visibility }}</span>{% endif %}</span>
5 5
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on"{% endif %}>Code</a>
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on"{% endif %}>Changes</a>
7 -
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals</a>
8 -
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues</a>
7 +
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
8 +
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a>
9 9
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a>
10 10
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on"{% endif %}>Releases</a>
11 11
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a>
…
62 unchanged lines
modified templates/style.css +2 −0
…
361 unchanged lines
362 362
.topic:hover { text-decoration: none; background: color-mix(in srgb, var(--acc) 24%, transparent); }
363 363
.explore-bar input[type=search] { flex: 1 1 240px; }
364 364
.counts-line span { margin-right: 2px; }
365 +
.count-chip { display: inline-block; min-width: 1.6em; padding: 0 6px; margin-left: 4px; border-radius: 9px; background: var(--soft); color: var(--mute); font-size: 11px; text-align: center; }
366 +
.pager { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 14px 0; }