Krolyxon
Commit 8f664fb

ferrenium8f664fb

Sort the repository index by activity by default

ui.index_sort (activity | name) sets the default order; ?sort= still
overrides it per request.
Author
krolyxon
Date
2026-11-05 23:15 +0530
Parent
e758199
Commit
8f664fbbca26b4ba993c755e9c7b3a139a0c926c

+70 −12

5 files changed

  1. M dist/ferrenium.toml +1 −0
  2. M src/config.rs +23 −0
  3. M src/handlers/index.rs +25 −8
  4. M templates/index.html +2 −2
  5. M tests/index.rs +19 −2
M dist/ferrenium.toml View
@@ -40,6 +40,7 @@ https_clone = true
4040
4141 [ui]
4242 theme = "auto" # auto | dark | light
43+index_sort = "activity" # repository index order: activity | name
4344 code_font = "Space Mono"
4445 max_blob_kb = 1024
4546 max_highlight_kb = 256
M src/config.rs View
@@ -154,6 +154,8 @@ pub struct RepoOverride {
154154 #[serde(deny_unknown_fields, default)]
155155 pub struct UiConfig {
156156 pub theme: Theme,
157+ /// Default order of the repository index (`?sort=` overrides it).
158+ pub index_sort: IndexSort,
157159 pub code_font: String,
158160 pub max_blob_kb: u64,
159161 pub max_highlight_kb: u64,
@@ -165,6 +167,7 @@ impl Default for UiConfig {
165167 fn default() -> Self {
166168 Self {
167169 theme: Theme::Auto,
170+ index_sort: IndexSort::Activity,
168171 code_font: DEFAULT_CODE_FONT.into(),
169172 max_blob_kb: 1024,
170173 max_highlight_kb: 256,
@@ -175,6 +178,25 @@ impl Default for UiConfig {
175178
176179 pub const DEFAULT_CODE_FONT: &str = "Space Mono";
177180
181+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
182+#[serde(rename_all = "lowercase")]
183+pub enum IndexSort {
184+ /// Most recently pushed first, as one list.
185+ #[default]
186+ Activity,
187+ /// By section, then name.
188+ Name,
189+}
190+
191+impl IndexSort {
192+ pub fn as_str(self) -> &'static str {
193+ match self {
194+ IndexSort::Activity => "activity",
195+ IndexSort::Name => "name",
196+ }
197+ }
198+}
199+
178200 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
179201 #[serde(rename_all = "lowercase")]
180202 pub enum Theme {
@@ -558,6 +580,7 @@ mod tests {
558580 assert_eq!(c.repos.scan_path, PathBuf::from("/home/git"));
559581 assert_eq!(c.repos.max_depth, 3);
560582 assert_eq!(c.ui.theme, Theme::Auto);
583+ assert_eq!(c.ui.index_sort, IndexSort::Activity);
561584 assert_eq!(c.cache.size_mb, 64);
562585 assert_eq!(
563586 c.repos.snapshots,
M src/handlers/index.rs View
@@ -9,6 +9,7 @@ use axum::response::Response;
99 use serde::Deserialize;
1010
1111 use super::{Layout, render};
12+use crate::config::IndexSort;
1213 use crate::index::Repo;
1314 use crate::render::{encode_path, time};
1415 use crate::state::AppState;
@@ -53,6 +54,8 @@ pub struct IndexPage {
5354 pub shown: usize,
5455 pub q: String,
5556 pub by_activity: bool,
57+ /// `?sort=` value to keep when filtering; `None` for the default order.
58+ pub sort_param: Option<&'static str>,
5659 pub sort_name_url: String,
5760 pub sort_activity_url: String,
5861 pub sections: Vec<Section>,
@@ -64,7 +67,13 @@ pub async fn index(
6467 ) -> Response {
6568 let config = state.config.load();
6669 let index = state.index.load();
67− let by_activity = query.sort == "activity";
70+ let default_sort = config.ui.index_sort;
71+ let sort = match query.sort.as_str() {
72+ "activity" => IndexSort::Activity,
73+ "name" => IndexSort::Name,
74+ _ => default_sort,
75+ };
76+ let by_activity = sort == IndexSort::Activity;
6877 let q = query.q.trim().to_string();
6978 let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect();
7079 let now = time::now();
@@ -127,21 +136,29 @@ pub async fn index(
127136 }
128137 }
129138
130− let sort_url = |sort: &str| {
131− let mut url = format!("{base}?sort={sort}");
139+ // The default order needs no `sort` parameter.
140+ let sort_url = |sort: IndexSort| {
141+ let mut params = Vec::new();
142+ if sort != default_sort {
143+ params.push(format!("sort={}", sort.as_str()));
144+ }
132145 if !q.is_empty() {
133− url.push_str("&q=");
134− url.push_str(&encode_path(&q));
146+ params.push(format!("q={}", encode_path(&q)));
147+ }
148+ if params.is_empty() {
149+ base.clone()
150+ } else {
151+ format!("{base}?{}", params.join("&"))
135152 }
136− url
137153 };
138154 let page = IndexPage {
139155 layout: Layout::new(&state, &config, None),
140156 description: config.site.description.clone(),
141157 total: index.repos.len(),
142158 shown,
143− sort_name_url: sort_url("name"),
144− sort_activity_url: sort_url("activity"),
159+ sort_param: (sort != default_sort).then(|| sort.as_str()),
160+ sort_name_url: sort_url(IndexSort::Name),
161+ sort_activity_url: sort_url(IndexSort::Activity),
145162 q,
146163 by_activity,
147164 sections,
M templates/index.html View
@@ -20,8 +20,8 @@
2020 <span class="label">Filter</span>
2121 <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="/" aria-keyshortcuts="/" data-filter>
2222 </label>
23− {%- if by_activity %}
24− <input type="hidden" name="sort" value="activity">
23+ {%- if let Some(sort) = sort_param %}
24+ <input type="hidden" name="sort" value="{{ sort }}">
2525 {%- endif %}
2626 <nav class="sort" aria-label="Sort by">
2727 <a class="label{% if !by_activity %} active{% endif %}" href="{{ sort_name_url }}"{% if !by_activity %} aria-current="true"{% endif %}>Name</a>
M tests/index.rs View
@@ -190,7 +190,12 @@ async fn index_page_lists_sections_filter_and_sort() {
190190 bare(r, "empty.git", &[]);
191191 let state = state(config(r, "", ""));
192192
193− let html = page(&state, "/").await;
193+ // Default order: by activity, one list.
194+ let default = page(&state, "/").await;
195+ assert!(!default.contains("[ greek ]"));
196+ assert!(!default.contains(r#"name="sort""#));
197+
198+ let html = page(&state, "/?sort=name").await;
194199 assert!(html.contains("4 Repositories"));
195200 assert!(html.contains(r#"href="/alpha""#));
196201 assert!(html.contains(r#"href="/with%20space""#));
@@ -212,9 +217,21 @@ async fn index_page_lists_sections_filter_and_sort() {
212217 assert!(none.contains("[No matches]"));
213218 assert!(!none.contains("data-filter-empty hidden"));
214219
220+ assert!(html.contains(r#"name="sort" value="name""#));
215221 let by_activity = page(&state, "/?sort=activity").await;
216222 assert!(!by_activity.contains("[ greek ]"));
217− assert!(by_activity.contains(r#"name="sort" value="activity""#));
223+ assert!(!by_activity.contains(r#"name="sort""#));
224+
225+ // `ui.index_sort = "name"` makes name order the default.
226+ let state = state_for_name_sort(r);
227+ let html = page(&state, "/").await;
228+ assert!(html.contains("[ greek ]"));
229+ assert!(!html.contains(r#"name="sort""#));
230+ assert!(html.contains(r#"href="/?sort=activity""#));
231+}
232+
233+fn state_for_name_sort(root: &std::path::Path) -> Arc<AppState> {
234+ state(config(root, "", "[ui]\nindex_sort = \"name\"\n"))
218235 }
219236
220237 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]