ferrenium8f664fb
Sort the repository index by activity by default
ui.index_sort (activity | name) sets the default order; ?sort= still overrides it per request.
+70 −12
5 files changed
- M dist/ferrenium.toml +1 −0
- M src/config.rs +23 −0
- M src/handlers/index.rs +25 −8
- M templates/index.html +2 −2
- M tests/index.rs +19 −2
| @@ -40,6 +40,7 @@ https_clone = true | |||
| 40 | 40 | ||
| 41 | 41 | [ui] | |
| 42 | 42 | theme = "auto" # auto | dark | light | |
| 43 | + | index_sort = "activity" # repository index order: activity | name | |
| 43 | 44 | code_font = "Space Mono" | |
| 44 | 45 | max_blob_kb = 1024 | |
| 45 | 46 | max_highlight_kb = 256 | |
| @@ -154,6 +154,8 @@ pub struct RepoOverride { | |||
| 154 | 154 | #[serde(deny_unknown_fields, default)] | |
| 155 | 155 | pub struct UiConfig { | |
| 156 | 156 | pub theme: Theme, | |
| 157 | + | /// Default order of the repository index (`?sort=` overrides it). | |
| 158 | + | pub index_sort: IndexSort, | |
| 157 | 159 | pub code_font: String, | |
| 158 | 160 | pub max_blob_kb: u64, | |
| 159 | 161 | pub max_highlight_kb: u64, | |
| @@ -165,6 +167,7 @@ impl Default for UiConfig { | |||
| 165 | 167 | fn default() -> Self { | |
| 166 | 168 | Self { | |
| 167 | 169 | theme: Theme::Auto, | |
| 170 | + | index_sort: IndexSort::Activity, | |
| 168 | 171 | code_font: DEFAULT_CODE_FONT.into(), | |
| 169 | 172 | max_blob_kb: 1024, | |
| 170 | 173 | max_highlight_kb: 256, | |
| @@ -175,6 +178,25 @@ impl Default for UiConfig { | |||
| 175 | 178 | ||
| 176 | 179 | pub const DEFAULT_CODE_FONT: &str = "Space Mono"; | |
| 177 | 180 | ||
| 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 | + | ||
| 178 | 200 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] | |
| 179 | 201 | #[serde(rename_all = "lowercase")] | |
| 180 | 202 | pub enum Theme { | |
| @@ -558,6 +580,7 @@ mod tests { | |||
| 558 | 580 | assert_eq!(c.repos.scan_path, PathBuf::from("/home/git")); | |
| 559 | 581 | assert_eq!(c.repos.max_depth, 3); | |
| 560 | 582 | assert_eq!(c.ui.theme, Theme::Auto); | |
| 583 | + | assert_eq!(c.ui.index_sort, IndexSort::Activity); | |
| 561 | 584 | assert_eq!(c.cache.size_mb, 64); | |
| 562 | 585 | assert_eq!( | |
| 563 | 586 | c.repos.snapshots, | |
| @@ -9,6 +9,7 @@ use axum::response::Response; | |||
| 9 | 9 | use serde::Deserialize; | |
| 10 | 10 | ||
| 11 | 11 | use super::{Layout, render}; | |
| 12 | + | use crate::config::IndexSort; | |
| 12 | 13 | use crate::index::Repo; | |
| 13 | 14 | use crate::render::{encode_path, time}; | |
| 14 | 15 | use crate::state::AppState; | |
| @@ -53,6 +54,8 @@ pub struct IndexPage { | |||
| 53 | 54 | pub shown: usize, | |
| 54 | 55 | pub q: String, | |
| 55 | 56 | pub by_activity: bool, | |
| 57 | + | /// `?sort=` value to keep when filtering; `None` for the default order. | |
| 58 | + | pub sort_param: Option<&'static str>, | |
| 56 | 59 | pub sort_name_url: String, | |
| 57 | 60 | pub sort_activity_url: String, | |
| 58 | 61 | pub sections: Vec<Section>, | |
| @@ -64,7 +67,13 @@ pub async fn index( | |||
| 64 | 67 | ) -> Response { | |
| 65 | 68 | let config = state.config.load(); | |
| 66 | 69 | 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; | |
| 68 | 77 | let q = query.q.trim().to_string(); | |
| 69 | 78 | let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect(); | |
| 70 | 79 | let now = time::now(); | |
| @@ -127,21 +136,29 @@ pub async fn index( | |||
| 127 | 136 | } | |
| 128 | 137 | } | |
| 129 | 138 | ||
| 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 | + | } | |
| 132 | 145 | 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("&")) | |
| 135 | 152 | } | |
| 136 | − | url | |
| 137 | 153 | }; | |
| 138 | 154 | let page = IndexPage { | |
| 139 | 155 | layout: Layout::new(&state, &config, None), | |
| 140 | 156 | description: config.site.description.clone(), | |
| 141 | 157 | total: index.repos.len(), | |
| 142 | 158 | 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), | |
| 145 | 162 | q, | |
| 146 | 163 | by_activity, | |
| 147 | 164 | sections, | |
| @@ -20,8 +20,8 @@ | |||
| 20 | 20 | <span class="label">Filter</span> | |
| 21 | 21 | <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="/" aria-keyshortcuts="/" data-filter> | |
| 22 | 22 | </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 }}"> | |
| 25 | 25 | {%- endif %} | |
| 26 | 26 | <nav class="sort" aria-label="Sort by"> | |
| 27 | 27 | <a class="label{% if !by_activity %} active{% endif %}" href="{{ sort_name_url }}"{% if !by_activity %} aria-current="true"{% endif %}>Name</a> | |
| @@ -190,7 +190,12 @@ async fn index_page_lists_sections_filter_and_sort() { | |||
| 190 | 190 | bare(r, "empty.git", &[]); | |
| 191 | 191 | let state = state(config(r, "", "")); | |
| 192 | 192 | ||
| 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; | |
| 194 | 199 | assert!(html.contains("4 Repositories")); | |
| 195 | 200 | assert!(html.contains(r#"href="/alpha""#)); | |
| 196 | 201 | assert!(html.contains(r#"href="/with%20space""#)); | |
| @@ -212,9 +217,21 @@ async fn index_page_lists_sections_filter_and_sort() { | |||
| 212 | 217 | assert!(none.contains("[No matches]")); | |
| 213 | 218 | assert!(!none.contains("data-filter-empty hidden")); | |
| 214 | 219 | ||
| 220 | + | assert!(html.contains(r#"name="sort" value="name""#)); | |
| 215 | 221 | let by_activity = page(&state, "/?sort=activity").await; | |
| 216 | 222 | 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")) | |
| 218 | 235 | } | |
| 219 | 236 | ||
| 220 | 237 | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |