State cache keyed by heads; history and graph through oxy-core's lanes and log window 9ff27da517fe
Every view loads states through a bounded LRU shared across threads, so a warm overview of oxygen renders in under 2 ms instead of 300 ms.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 18:05 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9ff27da517feccb327d434773d0334aac5875014513332fdb77787069978b41f- Depends on
- d23464c0ac60
- Depended on by
- ba0a55bb5b3c 7c06f5b28ade d2077a57b1f9 ffb67a81188c 39442b000e6b b210727578e8 736ae87ef318 2e640ffa6c93 df15b74774ca e271a37b629a 83d5052bf76c b19606f28101 75d611ab99d1 6ae41081ccd3 3f4b0ea6eabf 145306044896 b64e2537d702 de505df5cf8d 19bb31ff1f5c e1078e99c560
- 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
- 77 atoms, 1 tree operation, 11 files
Browse the tree at this change
added crates/hub/src/repos/cache.rs +99 −0
| 1 | + | //! Loaded states cached by their heads (hub spec §16.3). A state is a pure |
|
| 2 | + | //! function of its heads, so entries never go stale; the cache is only |
|
| 3 | + | //! bounded. Shared across threads because `oxy_core::state::State` is |
|
| 4 | + | //! `Send + Sync`. |
|
| 5 | + | ||
| 6 | + | use oxy_core::hash::ChangeHash; |
|
| 7 | + | use oxy_core::repo::Repo; |
|
| 8 | + | use oxy_core::state::{Snapshot, State}; |
|
| 9 | + | use std::collections::HashMap; |
|
| 10 | + | use std::sync::{Arc, Mutex}; |
|
| 11 | + | ||
| 12 | + | /// A state and its materialised snapshot. |
|
| 13 | + | pub struct Loaded { |
|
| 14 | + | pub heads: Vec<ChangeHash>, |
|
| 15 | + | pub state: State, |
|
| 16 | + | pub snapshot: Snapshot, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[derive(Clone, Debug, PartialEq, Eq, Hash)] |
|
| 20 | + | struct Key { |
|
| 21 | + | repo: [u8; 16], |
|
| 22 | + | heads: Vec<ChangeHash>, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | struct Entry { |
|
| 26 | + | loaded: Arc<Loaded>, |
|
| 27 | + | last_used: u64, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | /// A bounded least-recently-used cache of loaded states. |
|
| 31 | + | pub struct StateCache { |
|
| 32 | + | capacity: usize, |
|
| 33 | + | inner: Mutex<(HashMap<Key, Entry>, u64)>, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl StateCache { |
|
| 37 | + | pub fn new(capacity: usize) -> StateCache { |
|
| 38 | + | StateCache { capacity: capacity.max(1), inner: Mutex::new((HashMap::new(), 0)) } |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// The state with these heads, loaded once per hub process. |
|
| 42 | + | pub fn load(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Loaded>, oxy_core::repo::RepoError> { |
|
| 43 | + | let mut sorted = heads.to_vec(); |
|
| 44 | + | sorted.sort(); |
|
| 45 | + | sorted.dedup(); |
|
| 46 | + | let key = Key { repo: repo.id()?, heads: sorted.clone() }; |
|
| 47 | + | { |
|
| 48 | + | let mut guard = self.inner.lock().unwrap(); |
|
| 49 | + | let (map, tick) = &mut *guard; |
|
| 50 | + | *tick += 1; |
|
| 51 | + | if let Some(e) = map.get_mut(&key) { |
|
| 52 | + | e.last_used = *tick; |
|
| 53 | + | return Ok(e.loaded.clone()); |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | let state = repo.load_state(&sorted)?; |
|
| 57 | + | let snapshot = state.materialize(); |
|
| 58 | + | let loaded = Arc::new(Loaded { heads: sorted, state, snapshot }); |
|
| 59 | + | let mut guard = self.inner.lock().unwrap(); |
|
| 60 | + | let (map, tick) = &mut *guard; |
|
| 61 | + | if map.len() >= self.capacity { |
|
| 62 | + | if let Some(oldest) = map.iter().min_by_key(|(_, e)| e.last_used).map(|(k, _)| k.clone()) { |
|
| 63 | + | map.remove(&oldest); |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | map.insert(key, Entry { loaded: loaded.clone(), last_used: *tick }); |
|
| 67 | + | Ok(loaded) |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | pub fn len(&self) -> usize { |
|
| 71 | + | self.inner.lock().unwrap().0.len() |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | pub fn is_empty(&self) -> bool { |
|
| 75 | + | self.len() == 0 |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | #[cfg(test)] |
|
| 80 | + | mod tests { |
|
| 81 | + | use super::*; |
|
| 82 | + | ||
| 83 | + | #[test] |
|
| 84 | + | fn caches_by_heads_and_evicts_the_oldest() { |
|
| 85 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 86 | + | let repo = Repo::init_bare(dir.path()).unwrap(); |
|
| 87 | + | let cache = StateCache::new(2); |
|
| 88 | + | let a = cache.load(&repo, &[]).unwrap(); |
|
| 89 | + | let b = cache.load(&repo, &[]).unwrap(); |
|
| 90 | + | assert!(Arc::ptr_eq(&a, &b), "same heads, same entry"); |
|
| 91 | + | assert_eq!(cache.len(), 1); |
|
| 92 | + | let h1 = oxy_core::hash::ObjectRef([1; 32]); |
|
| 93 | + | let h2 = oxy_core::hash::ObjectRef([2; 32]); |
|
| 94 | + | // Unknown heads fail to load and are not cached. |
|
| 95 | + | assert!(cache.load(&repo, &[h1]).is_err()); |
|
| 96 | + | assert_eq!(cache.len(), 1); |
|
| 97 | + | let _ = (h2, dir); |
|
| 98 | + | } |
|
| 99 | + | } |
modified crates/hub/src/api/mod.rs +2 −2
| … | 73 unchanged lines |
||
| 74 | 74 | async fn bookmarks(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Bookmark>>, ApiError> { |
|
| 75 | 75 | let (repo, row) = public_row(&app, &owner, &name).await?; |
|
| 76 | 76 | let default = row.default_bookmark.clone(); |
|
| 77 | - | let list = app.repos.read(&repo, move |r| view::bookmarks(r, &default)).await?; |
|
| 77 | + | let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?; |
|
| 78 | 78 | Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) |
|
| 79 | 79 | } |
|
| 80 | 80 | ||
| 81 | 81 | async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Tag>>, ApiError> { |
|
| 82 | 82 | let (repo, _) = public_row(&app, &owner, &name).await?; |
|
| 83 | - | let list = app.repos.read(&repo, view::tags).await?; |
|
| 83 | + | let list = app.repos.read(&repo, |r, _| view::tags(r)).await?; |
|
| 84 | 84 | Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect())) |
|
| 85 | 85 | } |
modified crates/hub/src/main.rs +1 −1
| … | 185 unchanged lines |
||
| 186 | 186 | let mut problems = 0; |
|
| 187 | 187 | for r in db::repos::list(&app.db).await? { |
|
| 188 | 188 | let name = RepoName::new(&r.owner, &r.name).unwrap(); |
|
| 189 | - | let report = app.repos.read(&name, oxygenhub::repos::view::verification).await; |
|
| 189 | + | let report = app.repos.read(&name, |r, cache| oxygenhub::repos::view::verification(r, cache)).await; |
|
| 190 | 190 | match report { |
|
| 191 | 191 | Ok(v) => { |
|
| 192 | 192 | println!( |
|
| … | 21 unchanged lines |
modified crates/hub/src/render/diff.rs +6 −0
| … | 165 unchanged lines |
||
| 166 | 166 | hunks |
|
| 167 | 167 | } |
|
| 168 | 168 | ||
| 169 | + | /// The per-file diff of one change against its base state (what |
|
| 170 | + | /// `oxy diff CHANGE` shows), using an already loaded base. |
|
| 171 | + | pub fn change_files(change: &oxy_core::objects::Change, base: &oxy_core::state::State, base_snapshot: &Snapshot) -> Vec<FileDiff> { |
|
| 172 | + | oxy_core::repo::diff_files_of(change, base, base_snapshot) |
|
| 173 | + | } |
|
| 174 | + | ||
| 169 | 175 | /// Every file that differs between two snapshots, with the base content |
|
| 170 | 176 | /// of files present in both (for context). |
|
| 171 | 177 | pub fn snapshot_diff(base: &Snapshot, head: &Snapshot) -> (Vec<FileDiff>, BTreeMap<String, Vec<u8>>) { |
|
| … | 64 unchanged lines |
modified crates/hub/src/render/graph.rs +8 −114
| 1 | - | //! Lane layout for the history graph (hub spec §6.7): each change takes a |
|
| 2 | - | //! lane, its first dependency continues that lane, other dependencies |
|
| 3 | - | //! take free lanes, so edges flow downward from a change to what it |
|
| 4 | - | //! depends on. Ported from the VS Code extension's webview. |
|
| 5 | - | ||
| 6 | - | use std::collections::HashMap; |
|
| 1 | + | //! The history graph's lane layout is `oxy_core::lanes`, shared with `oxy |
|
| 2 | + | //! log`; this module only adapts hex hashes from views to it. |
|
| 7 | 3 | ||
| 8 | - | /// A row of the graph: the change's lane and the edges to the rows below. |
|
| 9 | - | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 10 | - | pub struct Row { |
|
| 11 | - | pub lane: usize, |
|
| 12 | - | /// `(from_lane, to_lane)` for each dependency drawn from this row. |
|
| 13 | - | pub edges: Vec<(usize, usize)>, |
|
| 14 | - | /// Lanes that pass through this row without touching it. |
|
| 15 | - | pub through: Vec<usize>, |
|
| 16 | - | } |
|
| 4 | + | pub use oxy_core::lanes::{width, Row}; |
|
| 17 | 5 | ||
| 18 | - | /// `changes` newest first as `(hash, deps)`. Dependencies outside the list |
|
| 19 | - | /// end their lane at the bottom. |
|
| 6 | + | /// `changes` newest first as `(hash hex, deps hex)`. |
|
| 20 | 7 | pub fn layout(changes: &[(String, Vec<String>)]) -> Vec<Row> { |
|
| 21 | - | let mut active: Vec<Option<String>> = Vec::new(); |
|
| 22 | - | let mut rows = Vec::with_capacity(changes.len()); |
|
| 23 | - | let known: std::collections::HashSet<&str> = changes.iter().map(|(h, _)| h.as_str()).collect(); |
|
| 24 | - | let mut lanes: HashMap<String, usize> = HashMap::new(); |
|
| 25 | - | for (hash, deps) in changes { |
|
| 26 | - | let lane = match active.iter().position(|a| a.as_deref() == Some(hash)) { |
|
| 27 | - | Some(l) => l, |
|
| 28 | - | None => match active.iter().position(|a| a.is_none()) { |
|
| 29 | - | Some(l) => { |
|
| 30 | - | active[l] = Some(hash.clone()); |
|
| 31 | - | l |
|
| 32 | - | } |
|
| 33 | - | None => { |
|
| 34 | - | active.push(Some(hash.clone())); |
|
| 35 | - | active.len() - 1 |
|
| 36 | - | } |
|
| 37 | - | }, |
|
| 38 | - | }; |
|
| 39 | - | lanes.insert(hash.clone(), lane); |
|
| 40 | - | let mut edges = Vec::new(); |
|
| 41 | - | let deps: Vec<&String> = deps.iter().filter(|d| known.contains(d.as_str())).collect(); |
|
| 42 | - | active[lane] = None; |
|
| 43 | - | for (i, d) in deps.iter().enumerate() { |
|
| 44 | - | let target = match active.iter().position(|a| a.as_deref() == Some(d.as_str())) { |
|
| 45 | - | Some(l) => l, |
|
| 46 | - | None => { |
|
| 47 | - | let l = if i == 0 && active[lane].is_none() { |
|
| 48 | - | lane |
|
| 49 | - | } else { |
|
| 50 | - | match active.iter().position(|a| a.is_none()) { |
|
| 51 | - | Some(l) => l, |
|
| 52 | - | None => { |
|
| 53 | - | active.push(None); |
|
| 54 | - | active.len() - 1 |
|
| 55 | - | } |
|
| 56 | - | } |
|
| 57 | - | }; |
|
| 58 | - | active[l] = Some((*d).clone()); |
|
| 59 | - | l |
|
| 60 | - | } |
|
| 61 | - | }; |
|
| 62 | - | edges.push((lane, target)); |
|
| 63 | - | } |
|
| 64 | - | let through: Vec<usize> = active |
|
| 65 | - | .iter() |
|
| 66 | - | .enumerate() |
|
| 67 | - | .filter(|(l, a)| a.is_some() && *l != lane && !edges.iter().any(|e| e.1 == *l)) |
|
| 68 | - | .map(|(l, _)| l) |
|
| 69 | - | .collect(); |
|
| 70 | - | rows.push(Row { lane, edges, through }); |
|
| 71 | - | // Trim trailing free lanes so the graph stays narrow. |
|
| 72 | - | while active.last().is_some_and(|a| a.is_none()) { |
|
| 73 | - | active.pop(); |
|
| 74 | - | } |
|
| 75 | - | } |
|
| 76 | - | rows |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | pub fn width(rows: &[Row]) -> usize { |
|
| 80 | - | rows.iter() |
|
| 81 | - | .map(|r| r.lane.max(r.edges.iter().map(|e| e.1).max().unwrap_or(0)).max(r.through.iter().copied().max().unwrap_or(0))) |
|
| 82 | - | .max() |
|
| 83 | - | .map(|m| m + 1) |
|
| 84 | - | .unwrap_or(1) |
|
| 85 | - | } |
|
| 86 | - | ||
| 87 | - | #[cfg(test)] |
|
| 88 | - | mod tests { |
|
| 89 | - | use super::*; |
|
| 90 | - | ||
| 91 | - | fn c(h: &str, deps: &[&str]) -> (String, Vec<String>) { |
|
| 92 | - | (h.into(), deps.iter().map(|d| d.to_string()).collect()) |
|
| 93 | - | } |
|
| 94 | - | ||
| 95 | - | #[test] |
|
| 96 | - | fn linear_history_is_one_lane() { |
|
| 97 | - | let rows = layout(&[c("c", &["b"]), c("b", &["a"]), c("a", &[])]); |
|
| 98 | - | assert!(rows.iter().all(|r| r.lane == 0), "{rows:?}"); |
|
| 99 | - | assert_eq!(rows[0].edges, vec![(0, 0)]); |
|
| 100 | - | assert_eq!(width(&rows), 1); |
|
| 101 | - | } |
|
| 102 | - | ||
| 103 | - | #[test] |
|
| 104 | - | fn independent_changes_take_lanes_and_rejoin() { |
|
| 105 | - | // d depends on b and c, which both depend on a: a diamond. |
|
| 106 | - | let rows = layout(&[c("d", &["b", "c"]), c("b", &["a"]), c("c", &["a"]), c("a", &[])]); |
|
| 107 | - | assert_eq!(rows[0].lane, 0); |
|
| 108 | - | assert_eq!(rows[0].edges, vec![(0, 0), (0, 1)]); |
|
| 109 | - | assert_eq!(rows[1].lane, 0); |
|
| 110 | - | assert_eq!(rows[2].lane, 1); |
|
| 111 | - | assert_eq!(rows[2].edges, vec![(1, 0)], "c joins a's lane"); |
|
| 112 | - | assert_eq!(rows[3].lane, 0); |
|
| 113 | - | assert_eq!(width(&rows), 2); |
|
| 114 | - | // Two heads with no common change: two lanes throughout. |
|
| 115 | - | let rows = layout(&[c("x", &[]), c("y", &[])]); |
|
| 116 | - | assert_eq!((rows[0].lane, rows[1].lane), (0, 0), "a lane is reused once its change is drawn"); |
|
| 117 | - | } |
|
| 8 | + | let parse = |h: &str| oxy_core::hash::ObjectRef::from_hex(h).unwrap_or(oxy_core::hash::ObjectRef([0; 32])); |
|
| 9 | + | let input: Vec<(oxy_core::hash::ChangeHash, Vec<oxy_core::hash::ChangeHash>)> = |
|
| 10 | + | changes.iter().map(|(h, d)| (parse(h), d.iter().map(|x| parse(x)).collect())).collect(); |
|
| 11 | + | oxy_core::lanes::layout(&input) |
|
| 118 | 12 | } |
modified crates/hub/src/repos/mod.rs +14 −5
| … | 4 unchanged lines |
||
| 5 | 5 | //! the repository's lock (§16.2): pushes and other writes take the write |
|
| 6 | 6 | //! half, page reads the read half. |
|
| 7 | 7 | ||
| 8 | + | pub mod cache; |
|
| 8 | 9 | pub mod names; |
|
| 9 | 10 | pub mod view; |
|
| 10 | 11 | ||
| 12 | + | pub use cache::{Loaded, StateCache}; |
|
| 11 | 13 | pub use names::RepoName; |
|
| 12 | 14 | ||
| 13 | 15 | use oxy_core::repo::Repo; |
|
| … | 6 unchanged lines |
||
| 20 | 22 | pub struct Repos { |
|
| 21 | 23 | root: PathBuf, |
|
| 22 | 24 | locks: Mutex<HashMap<String, Arc<RwLock<()>>>>, |
|
| 25 | + | cache: Arc<StateCache>, |
|
| 23 | 26 | } |
|
| 24 | 27 | ||
| 25 | 28 | #[derive(Debug, thiserror::Error)] |
|
| … | 12 unchanged lines |
||
| 38 | 41 | ||
| 39 | 42 | impl Repos { |
|
| 40 | 43 | pub fn new(root: PathBuf) -> Repos { |
|
| 41 | - | Repos { root, locks: Mutex::new(HashMap::new()) } |
|
| 44 | + | Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)) } |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | pub fn cache(&self) -> &Arc<StateCache> { |
|
| 48 | + | &self.cache |
|
| 42 | 49 | } |
|
| 43 | 50 | ||
| 44 | 51 | pub fn root(&self) -> &Path { |
|
| … | 52 unchanged lines |
||
| 97 | 104 | pub async fn read<T: Send + 'static>( |
|
| 98 | 105 | &self, |
|
| 99 | 106 | name: &RepoName, |
|
| 100 | - | f: impl FnOnce(&Repo) -> Result<T, RepoError> + Send + 'static, |
|
| 107 | + | f: impl FnOnce(&Repo, &StateCache) -> Result<T, RepoError> + Send + 'static, |
|
| 101 | 108 | ) -> Result<T, RepoError> { |
|
| 102 | 109 | let lock = self.lock(name); |
|
| 103 | 110 | let guard = lock.read_owned().await; |
|
| 104 | 111 | let dir = self.dir(name); |
|
| 105 | 112 | let exists = self.exists(name); |
|
| 113 | + | let cache = self.cache.clone(); |
|
| 106 | 114 | tokio::task::spawn_blocking(move || { |
|
| 107 | 115 | let _guard = guard; |
|
| 108 | 116 | if !exists { |
|
| 109 | 117 | return Err(RepoError::NotFound); |
|
| 110 | 118 | } |
|
| 111 | 119 | let repo = Repo::open(&dir)?; |
|
| 112 | - | f(&repo) |
|
| 120 | + | f(&repo, &cache) |
|
| 113 | 121 | }) |
|
| 114 | 122 | .await |
|
| 115 | 123 | .map_err(|e| RepoError::Io(std::io::Error::other(e)))? |
|
| … | 3 unchanged lines |
||
| 119 | 127 | pub async fn write<T: Send + 'static>( |
|
| 120 | 128 | &self, |
|
| 121 | 129 | name: &RepoName, |
|
| 122 | - | f: impl FnOnce(&Repo) -> Result<T, RepoError> + Send + 'static, |
|
| 130 | + | f: impl FnOnce(&Repo, &StateCache) -> Result<T, RepoError> + Send + 'static, |
|
| 123 | 131 | ) -> Result<T, RepoError> { |
|
| 124 | 132 | let lock = self.lock(name); |
|
| 125 | 133 | let guard = lock.write_owned().await; |
|
| 126 | 134 | let dir = self.dir(name); |
|
| 127 | 135 | let exists = self.exists(name); |
|
| 136 | + | let cache = self.cache.clone(); |
|
| 128 | 137 | tokio::task::spawn_blocking(move || { |
|
| 129 | 138 | let _guard = guard; |
|
| 130 | 139 | if !exists { |
|
| 131 | 140 | return Err(RepoError::NotFound); |
|
| 132 | 141 | } |
|
| 133 | 142 | let repo = Repo::open(&dir)?; |
|
| 134 | - | f(&repo) |
|
| 143 | + | f(&repo, &cache) |
|
| 135 | 144 | }) |
|
| 136 | 145 | .await |
|
| 137 | 146 | .map_err(|e| RepoError::Io(std::io::Error::other(e)))? |
|
| … | 12 unchanged lines |
modified crates/hub/src/repos/view.rs +75 −61
| … | 1 unchanged lines |
||
| 2 | 2 | //! spec §1.4). Nothing here knows about HTTP or templates; every function |
|
| 3 | 3 | //! takes an open `Repo` and returns plain data. |
|
| 4 | 4 | ||
| 5 | - | use super::RepoError; |
|
| 5 | + | use super::{RepoError, StateCache}; |
|
| 6 | 6 | use oxy_core::hash::{ChangeHash, KeyId}; |
|
| 7 | 7 | use oxy_core::objects::{Change, NodeKind}; |
|
| 8 | 8 | use oxy_core::repo::{BlameLine, FileDiff, Repo, SignatureStatus}; |
|
| 9 | - | use oxy_core::state::{FileContent, Snapshot, State}; |
|
| 9 | + | use oxy_core::state::{FileContent, Snapshot}; |
|
| 10 | 10 | use oxy_core::tree::TreeConflict; |
|
| 11 | 11 | use std::collections::{BTreeMap, BTreeSet, HashMap}; |
|
| 12 | 12 | ||
| … | 22 unchanged lines |
||
| 35 | 35 | } |
|
| 36 | 36 | ||
| 37 | 37 | /// Resolve a bookmark name, a tag name, or a change hash prefix. |
|
| 38 | - | pub fn resolve(repo: &Repo, spec: &str) -> Result<RefTarget> { |
|
| 38 | + | pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result<RefTarget> { |
|
| 39 | 39 | let view = repo.view()?; |
|
| 40 | 40 | if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? { |
|
| 41 | 41 | return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads }); |
|
| … | 1 unchanged lines |
||
| 43 | 43 | if let Some(heads) = repo.tag_heads(spec)? { |
|
| 44 | 44 | return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads }); |
|
| 45 | 45 | } |
|
| 46 | - | let hash = resolve_change(repo, spec)?; |
|
| 46 | + | let hash = resolve_change(repo, cache, spec)?; |
|
| 47 | 47 | Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] }) |
|
| 48 | 48 | } |
|
| 49 | 49 | ||
| 50 | 50 | /// A change by full hash or unique hex prefix, searched over every |
|
| 51 | 51 | /// bookmark's state. |
|
| 52 | - | pub fn resolve_change(repo: &Repo, spec: &str) -> Result<ChangeHash> { |
|
| 52 | + | pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> { |
|
| 53 | 53 | let spec = spec.trim_start_matches('#'); |
|
| 54 | 54 | if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) { |
|
| 55 | 55 | return Err(fail(format!("{spec:?} is not a change"))); |
|
| … | 4 unchanged lines |
||
| 60 | 60 | } |
|
| 61 | 61 | } |
|
| 62 | 62 | let heads = all_heads(repo)?; |
|
| 63 | - | let state = repo.load_state(&heads)?; |
|
| 64 | - | let matches: Vec<ChangeHash> = state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect(); |
|
| 63 | + | let loaded = cache.load(repo, &heads)?; |
|
| 64 | + | let matches: Vec<ChangeHash> = loaded.state.changes().map(|(h, _)| *h).filter(|h| h.starts_with_hex(spec)).collect(); |
|
| 65 | 65 | match matches.len() { |
|
| 66 | 66 | 1 => Ok(matches[0]), |
|
| 67 | 67 | 0 => Err(fail(format!("no change matches {spec:?}"))), |
|
| … | 168 unchanged lines |
||
| 236 | 236 | } |
|
| 237 | 237 | ||
| 238 | 238 | /// A window of the history of a state, newest first. |
|
| 239 | - | pub fn history(repo: &Repo, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> { |
|
| 240 | - | let entries = match path { |
|
| 241 | - | Some(p) => repo.log_for_path_in(p, &target.heads)?, |
|
| 242 | - | None => repo.log_state(&target.heads)?, |
|
| 239 | + | pub fn history(repo: &Repo, cache: &StateCache, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> { |
|
| 240 | + | let loaded = cache.load(repo, &target.heads)?; |
|
| 241 | + | let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect(); |
|
| 242 | + | let mut entries: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = match path { |
|
| 243 | + | Some(p) => { |
|
| 244 | + | let Some(node) = loaded.snapshot.files.get(p).map(|f| f.node) else { return Err(fail(format!("{p} is not in that state"))) }; |
|
| 245 | + | loaded |
|
| 246 | + | .state |
|
| 247 | + | .changes() |
|
| 248 | + | .filter(|(h, _)| { |
|
| 249 | + | repo.store() |
|
| 250 | + | .get_change(h) |
|
| 251 | + | .map(|c| c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node)) |
|
| 252 | + | .unwrap_or(false) |
|
| 253 | + | }) |
|
| 254 | + | .map(|(h, m)| (*h, m.clone())) |
|
| 255 | + | .collect() |
|
| 256 | + | } |
|
| 257 | + | None => loaded.state.changes().map(|(h, m)| (*h, m.clone())).collect(), |
|
| 243 | 258 | }; |
|
| 259 | + | entries.sort_by(|a, b| b.1.hlc.cmp(&a.1.hlc).then(b.0.cmp(&a.0))); |
|
| 244 | 260 | let total = entries.len(); |
|
| 245 | 261 | let labels = head_labels(repo)?; |
|
| 246 | 262 | let window: Vec<ChangeView> = |
|
| 247 | - | entries.iter().skip(offset).take(limit).map(|e| change_view(repo, &e.hash, &e.change, e.is_head, &labels)).collect(); |
|
| 263 | + | entries.iter().skip(offset).take(limit).map(|(h, m)| change_view(repo, h, m, head_set.contains(h), &labels)).collect(); |
|
| 248 | 264 | Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) }) |
|
| 249 | 265 | } |
|
| 250 | 266 | ||
| … | 13 unchanged lines |
||
| 264 | 280 | pub tree_ops: usize, |
|
| 265 | 281 | } |
|
| 266 | 282 | ||
| 267 | - | pub fn change(repo: &Repo, hash: &ChangeHash) -> Result<ChangeDetail> { |
|
| 283 | + | pub fn change(repo: &Repo, cache: &StateCache, hash: &ChangeHash) -> Result<ChangeDetail> { |
|
| 268 | 284 | let c: Change = repo.store().get_change(hash)?; |
|
| 269 | 285 | let labels = head_labels(repo)?; |
|
| 270 | 286 | let heads = all_heads(repo)?; |
|
| 271 | - | let state = repo.load_state(&heads)?; |
|
| 287 | + | let loaded = cache.load(repo, &heads)?; |
|
| 288 | + | let state = &loaded.state; |
|
| 272 | 289 | let is_head = state.heads().contains(hash); |
|
| 273 | 290 | let meta = oxy_core::state::ChangeMeta::of(&c); |
|
| 274 | 291 | let view = change_view(repo, hash, &meta, is_head, &labels); |
|
| … | 4 unchanged lines |
||
| 279 | 296 | .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels)) |
|
| 280 | 297 | .collect(); |
|
| 281 | 298 | dependents.sort_by_key(|c| std::cmp::Reverse(c.when_ms)); |
|
| 282 | - | let files = repo.diff_files(Some(hash))?; |
|
| 283 | - | let base_state = repo.load_state(&c.deps)?; |
|
| 284 | - | let base_snapshot = base_state.materialize(); |
|
| 299 | + | let base = cache.load(repo, &c.deps)?; |
|
| 300 | + | let files = crate::render::diff::change_files(&c, &base.state, &base.snapshot); |
|
| 301 | + | let base_snapshot = &base.snapshot; |
|
| 285 | 302 | let mut bases = BTreeMap::new(); |
|
| 286 | 303 | for f in &files { |
|
| 287 | 304 | let old = f.from.as_deref().unwrap_or(&f.path); |
|
| … | 3 unchanged lines |
||
| 291 | 308 | } |
|
| 292 | 309 | let mut in_bookmarks = Vec::new(); |
|
| 293 | 310 | for b in repo.bookmarks()? { |
|
| 294 | - | if repo.load_state(&b.heads)?.contains(hash) { |
|
| 311 | + | if cache.load(repo, &b.heads)?.state.contains(hash) { |
|
| 295 | 312 | in_bookmarks.push(b.name); |
|
| 296 | 313 | } |
|
| 297 | 314 | } |
|
| 298 | 315 | let mut in_tags = Vec::new(); |
|
| 299 | 316 | for t in repo.tags()? { |
|
| 300 | - | if repo.load_state(&t.heads)?.contains(hash) { |
|
| 317 | + | if cache.load(repo, &t.heads)?.state.contains(hash) { |
|
| 301 | 318 | in_tags.push(t.name); |
|
| 302 | 319 | } |
|
| 303 | 320 | } |
|
| … | 64 unchanged lines |
||
| 368 | 385 | } |
|
| 369 | 386 | ||
| 370 | 387 | /// The entries directly under `dir` (`""` for the root). |
|
| 371 | - | pub fn tree(repo: &Repo, target: RefTarget, dir: &str) -> Result<Tree> { |
|
| 372 | - | let state = repo.load_state(&target.heads)?; |
|
| 373 | - | let snapshot = state.materialize(); |
|
| 374 | - | tree_of(repo, target, &snapshot, dir) |
|
| 388 | + | pub fn tree(repo: &Repo, cache: &StateCache, target: RefTarget, dir: &str) -> Result<Tree> { |
|
| 389 | + | let loaded = cache.load(repo, &target.heads)?; |
|
| 390 | + | tree_of(repo, target, &loaded.snapshot, dir) |
|
| 375 | 391 | } |
|
| 376 | 392 | ||
| 377 | 393 | pub fn tree_of(_repo: &Repo, target: RefTarget, snapshot: &Snapshot, dir: &str) -> Result<Tree> { |
|
| … | 51 unchanged lines |
||
| 429 | 445 | pub last_change: Option<ChangeView>, |
|
| 430 | 446 | } |
|
| 431 | 447 | ||
| 432 | - | pub fn file(repo: &Repo, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> { |
|
| 433 | - | let state = repo.load_state(&target.heads)?; |
|
| 434 | - | let snapshot = state.materialize(); |
|
| 448 | + | pub fn file(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> { |
|
| 449 | + | let loaded = cache.load(repo, &target.heads)?; |
|
| 450 | + | let snapshot = &loaded.snapshot; |
|
| 435 | 451 | let path = path.trim_matches('/'); |
|
| 436 | 452 | let Some(f) = snapshot.files.get(path) else { return Err(RepoError::NotFound) }; |
|
| 437 | 453 | let (kind, bytes) = match (&f.kind, &f.content) { |
|
| … | 50 unchanged lines |
||
| 488 | 504 | /// Last change per entry of a directory, for tree rows (fetched lazily). |
|
| 489 | 505 | /// A directory's last change is the newest change touching anything |
|
| 490 | 506 | /// beneath it, as GitHub shows it. |
|
| 491 | - | pub fn last_changes(repo: &Repo, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> { |
|
| 507 | + | pub fn last_changes(repo: &Repo, cache: &StateCache, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> { |
|
| 492 | 508 | let labels = head_labels(repo)?; |
|
| 493 | - | let state = repo.load_state(heads)?; |
|
| 494 | - | let snapshot = state.materialize(); |
|
| 509 | + | let loaded = cache.load(repo, heads)?; |
|
| 510 | + | let state = &loaded.state; |
|
| 511 | + | let snapshot = &loaded.snapshot; |
|
| 495 | 512 | // Newest change touching each node, from one pass over the log. |
|
| 496 | 513 | let mut newest: HashMap<oxy_core::ids::NodeId, (ChangeHash, u64)> = HashMap::new(); |
|
| 497 | 514 | for e in repo.log_state(heads)? { |
|
| … | 36 unchanged lines |
||
| 534 | 551 | pub lines: Vec<BlameLine>, |
|
| 535 | 552 | } |
|
| 536 | 553 | ||
| 537 | - | pub fn blame(repo: &Repo, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> { |
|
| 554 | + | pub fn blame(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> { |
|
| 538 | 555 | let heads = target.heads.clone(); |
|
| 539 | - | let file = file(repo, target, path, render_limit)?; |
|
| 556 | + | let file = file(repo, cache, target, path, render_limit)?; |
|
| 540 | 557 | let lines = repo.blame_in(path, &heads)?; |
|
| 541 | 558 | Ok(Blame { file, lines }) |
|
| 542 | 559 | } |
|
| … | 38 unchanged lines |
||
| 581 | 598 | Expired, |
|
| 582 | 599 | } |
|
| 583 | 600 | ||
| 584 | - | pub fn bookmarks(repo: &Repo, default: &str) -> Result<Vec<BookmarkView>> { |
|
| 601 | + | pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> { |
|
| 585 | 602 | let mut out = Vec::new(); |
|
| 586 | 603 | for b in repo.bookmarks()? { |
|
| 587 | - | let state = repo.load_state(&b.heads)?; |
|
| 604 | + | let loaded = cache.load(repo, &b.heads)?; |
|
| 605 | + | let state = &loaded.state; |
|
| 588 | 606 | let latest_ms = state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0); |
|
| 589 | - | let conflicted = state.materialize().is_conflicted(); |
|
| 607 | + | let conflicted = loaded.snapshot.is_conflicted(); |
|
| 590 | 608 | out.push(BookmarkView { |
|
| 591 | 609 | name: b.name.clone(), |
|
| 592 | 610 | heads: b.heads.iter().map(|h| h.to_hex()).collect(), |
|
| … | 30 unchanged lines |
||
| 623 | 641 | } |
|
| 624 | 642 | } |
|
| 625 | 643 | ||
| 626 | - | pub fn overview(repo: &Repo, default_bookmark: &str) -> Result<Overview> { |
|
| 627 | - | let target = match resolve(repo, default_bookmark) { |
|
| 644 | + | pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str) -> Result<Overview> { |
|
| 645 | + | let target = match resolve(repo, cache, default_bookmark) { |
|
| 628 | 646 | Ok(t) => t, |
|
| 629 | 647 | Err(_) => RefTarget { label: default_bookmark.into(), kind: RefKind::Bookmark, heads: vec![] }, |
|
| 630 | 648 | }; |
|
| 631 | 649 | let heads = target.heads.len(); |
|
| 632 | 650 | // One load of the default state serves the counts, the latest change |
|
| 633 | 651 | // and the tree (hub spec §16.3). |
|
| 634 | - | let state = repo.load_state(&target.heads)?; |
|
| 635 | - | let snapshot = state.materialize(); |
|
| 652 | + | let loaded = cache.load(repo, &target.heads)?; |
|
| 653 | + | let state = &loaded.state; |
|
| 654 | + | let snapshot = &loaded.snapshot; |
|
| 636 | 655 | let changes = state.len(); |
|
| 637 | 656 | let conflicted = snapshot.is_conflicted(); |
|
| 638 | 657 | let labels = head_labels(repo)?; |
|
| … | 1 unchanged lines |
||
| 640 | 659 | .changes() |
|
| 641 | 660 | .max_by(|(ha, a), (hb, b)| a.hlc.cmp(&b.hlc).then(ha.cmp(hb))) |
|
| 642 | 661 | .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels)); |
|
| 643 | - | let tree = tree_of(repo, target, &snapshot, "")?; |
|
| 662 | + | let tree = tree_of(repo, target, snapshot, "")?; |
|
| 644 | 663 | let license = tree |
|
| 645 | 664 | .entries |
|
| 646 | 665 | .iter() |
|
| … | 3 unchanged lines |
||
| 650 | 669 | tree, |
|
| 651 | 670 | changes, |
|
| 652 | 671 | heads, |
|
| 653 | - | bookmarks: bookmarks(repo, default_bookmark)?, |
|
| 672 | + | bookmarks: bookmarks(repo, cache, default_bookmark)?, |
|
| 654 | 673 | tags: tags(repo)?, |
|
| 655 | 674 | latest, |
|
| 656 | 675 | conflicted, |
|
| … | 16 unchanged lines |
||
| 673 | 692 | pub bases: BTreeMap<String, Vec<u8>>, |
|
| 674 | 693 | } |
|
| 675 | 694 | ||
| 676 | - | pub fn compare(repo: &Repo, base: RefTarget, head: RefTarget) -> Result<Compare> { |
|
| 677 | - | let base_state = repo.load_state(&base.heads)?; |
|
| 678 | - | let head_state = repo.load_state(&head.heads)?; |
|
| 695 | + | pub fn compare(repo: &Repo, cache: &StateCache, base: RefTarget, head: RefTarget) -> Result<Compare> { |
|
| 696 | + | let base_loaded = cache.load(repo, &base.heads)?; |
|
| 697 | + | let head_loaded = cache.load(repo, &head.heads)?; |
|
| 698 | + | let base_state = &base_loaded.state; |
|
| 699 | + | let head_state = &head_loaded.state; |
|
| 679 | 700 | let labels = head_labels(repo)?; |
|
| 680 | 701 | let mut added: Vec<ChangeView> = head_state |
|
| 681 | 702 | .changes() |
|
| … | 2 unchanged lines |
||
| 684 | 705 | .collect(); |
|
| 685 | 706 | added.sort_by_key(|c| std::cmp::Reverse(c.when_ms)); |
|
| 686 | 707 | let missing = base_state.changes().filter(|(h, _)| !head_state.contains(h)).count(); |
|
| 687 | - | let base_snapshot = base_state.materialize(); |
|
| 688 | - | let head_snapshot = head_state.materialize(); |
|
| 689 | - | let (files, bases) = crate::render::diff::snapshot_diff(&base_snapshot, &head_snapshot); |
|
| 708 | + | let (files, bases) = crate::render::diff::snapshot_diff(&base_loaded.snapshot, &head_loaded.snapshot); |
|
| 690 | 709 | Ok(Compare { base, head, added, missing, files, bases }) |
|
| 691 | 710 | } |
|
| 692 | 711 | ||
| … | 9 unchanged lines |
||
| 702 | 721 | pub contributors: Vec<ChangeView>, |
|
| 703 | 722 | } |
|
| 704 | 723 | ||
| 705 | - | pub fn conflicts(repo: &Repo) -> Result<Vec<ConflictView>> { |
|
| 724 | + | pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> { |
|
| 706 | 725 | let labels = head_labels(repo)?; |
|
| 707 | 726 | let mut out = Vec::new(); |
|
| 708 | 727 | for b in repo.bookmarks()? { |
|
| 709 | - | let state = repo.load_state(&b.heads)?; |
|
| 710 | - | let snapshot = state.materialize(); |
|
| 728 | + | let loaded = cache.load(repo, &b.heads)?; |
|
| 729 | + | let state = &loaded.state; |
|
| 730 | + | let snapshot = &loaded.snapshot; |
|
| 711 | 731 | for c in &snapshot.tree.conflicts { |
|
| 712 | - | let (path, kind) = describe_tree_conflict(&snapshot, c); |
|
| 732 | + | let (path, kind) = describe_tree_conflict(snapshot, c); |
|
| 713 | 733 | out.push(ConflictView { bookmark: b.name.clone(), path, kind, marked: None, contributors: vec![] }); |
|
| 714 | 734 | } |
|
| 715 | 735 | for (path, f) in &snapshot.files { |
|
| … | 90 unchanged lines |
||
| 806 | 826 | } |
|
| 807 | 827 | } |
|
| 808 | 828 | ||
| 809 | - | pub fn verification(repo: &Repo) -> Result<Verification> { |
|
| 829 | + | pub fn verification(repo: &Repo, cache: &StateCache) -> Result<Verification> { |
|
| 810 | 830 | let heads = all_heads(repo)?; |
|
| 811 | - | let state = repo.load_state(&heads)?; |
|
| 831 | + | let loaded = cache.load(repo, &heads)?; |
|
| 832 | + | let state = &loaded.state; |
|
| 812 | 833 | let mut v = Verification::default(); |
|
| 813 | 834 | for (h, _) in state.changes() { |
|
| 814 | 835 | v.total += 1; |
|
| … | 15 unchanged lines |
||
| 830 | 851 | Ok(v) |
|
| 831 | 852 | } |
|
| 832 | 853 | ||
| 833 | - | pub fn trust(repo: &Repo, hub_key: &KeyId) -> Result<Trust> { |
|
| 854 | + | pub fn trust(repo: &Repo, cache: &StateCache, hub_key: &KeyId) -> Result<Trust> { |
|
| 834 | 855 | let policy = match repo.current_policy()? { |
|
| 835 | 856 | Some((_, sp)) => { |
|
| 836 | 857 | let p = &sp.policy; |
|
| … | 37 unchanged lines |
||
| 874 | 895 | } |
|
| 875 | 896 | } |
|
| 876 | 897 | } |
|
| 877 | - | Ok(Trust { policy, verification: verification(repo)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated }) |
|
| 878 | - | } |
|
| 879 | - | ||
| 880 | - | // ------------------------------------------------------------- helpers |
|
| 881 | - | ||
| 882 | - | /// The state of a bookmark or any heads, for callers that need it raw. |
|
| 883 | - | pub fn state_of(repo: &Repo, heads: &[ChangeHash]) -> Result<State> { |
|
| 884 | - | Ok(repo.load_state(heads)?) |
|
| 898 | + | Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated }) |
|
| 885 | 899 | } |
modified crates/hub/src/web/history.rs +10 −10
| … | 39 unchanged lines |
||
| 40 | 40 | let offset = q.offset.unwrap_or(0); |
|
| 41 | 41 | let h = app |
|
| 42 | 42 | .repos |
|
| 43 | - | .read(&repo, move |repo| { |
|
| 44 | - | let target = view::resolve(repo, &spec)?; |
|
| 45 | - | view::history(repo, target, path.as_deref(), offset, PAGE) |
|
| 43 | + | .read(&repo, move |repo, cache| { |
|
| 44 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 45 | + | view::history(repo, cache, target, path.as_deref(), offset, PAGE) |
|
| 46 | 46 | }) |
|
| 47 | 47 | .await |
|
| 48 | 48 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| … | 53 unchanged lines |
||
| 102 | 102 | let spec = hash.clone(); |
|
| 103 | 103 | let c = app |
|
| 104 | 104 | .repos |
|
| 105 | - | .read(&repo, move |repo| { |
|
| 106 | - | let h = view::resolve_change(repo, &spec)?; |
|
| 107 | - | view::change(repo, &h) |
|
| 105 | + | .read(&repo, move |repo, cache| { |
|
| 106 | + | let h = view::resolve_change(repo, cache, &spec)?; |
|
| 107 | + | view::change(repo, cache, &h) |
|
| 108 | 108 | }) |
|
| 109 | 109 | .await |
|
| 110 | 110 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| … | 25 unchanged lines |
||
| 136 | 136 | let (a, b) = (a.to_string(), b.to_string()); |
|
| 137 | 137 | let cmp = app |
|
| 138 | 138 | .repos |
|
| 139 | - | .read(&repo, move |repo| { |
|
| 140 | - | let base = view::resolve(repo, &a)?; |
|
| 141 | - | let head = view::resolve(repo, &b)?; |
|
| 142 | - | view::compare(repo, base, head) |
|
| 139 | + | .read(&repo, move |repo, cache| { |
|
| 140 | + | let base = view::resolve(repo, cache, &a)?; |
|
| 141 | + | let head = view::resolve(repo, cache, &b)?; |
|
| 142 | + | view::compare(repo, cache, base, head) |
|
| 143 | 143 | }) |
|
| 144 | 144 | .await |
|
| 145 | 145 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| … | 3 unchanged lines |
modified crates/hub/src/web/refs.rs +4 −3
| … | 17 unchanged lines |
||
| 18 | 18 | let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks")); |
|
| 19 | 19 | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 20 | 20 | let default = row.default_bookmark.clone(); |
|
| 21 | - | let bookmarks = app.repos.read(&repo, move |r| view::bookmarks(r, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 21 | + | let bookmarks = |
|
| 22 | + | app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 22 | 23 | Ok(Page(BookmarksPage { shell, repo: RepoShell::new(&app, &row, "bookmarks"), bookmarks })) |
|
| 23 | 24 | } |
|
| 24 | 25 | ||
| … | 8 unchanged lines |
||
| 33 | 34 | pub async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> { |
|
| 34 | 35 | let shell = Shell::new(&app, &format!("/{owner}/{name}/tags")); |
|
| 35 | 36 | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 36 | - | let tags = app.repos.read(&repo, view::tags).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 37 | + | let tags = app.repos.read(&repo, |r, _| view::tags(r)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 37 | 38 | Ok(Page(TagsPage { shell, repo: RepoShell::new(&app, &row, "tags"), tags })) |
|
| 38 | 39 | } |
|
| 39 | 40 | ||
| … | 24 unchanged lines |
||
| 64 | 65 | let shell = Shell::new(&app, &format!("/{owner}/{name}/trust")); |
|
| 65 | 66 | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 66 | 67 | let key = app.hub_key.key; |
|
| 67 | - | let t = app.repos.read(&repo, move |r| view::trust(r, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 68 | + | let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 68 | 69 | Ok(Page(TrustPage { shell, repo: RepoShell::new(&app, &row, "trust"), t })) |
|
| 69 | 70 | } |
|
| 70 | 71 | ||
| … | 14 unchanged lines |
modified crates/hub/src/web/repo.rs +12 −11
| … | 21 unchanged lines |
||
| 22 | 22 | let shell = Shell::new(&app, &format!("/{owner}/{name}")); |
|
| 23 | 23 | let (repo, row) = lookup(&app, &shell, &owner, &name).await?; |
|
| 24 | 24 | let default = row.default_bookmark.clone(); |
|
| 25 | - | let o = app.repos.read(&repo, move |r| view::overview(r, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 25 | + | let o = |
|
| 26 | + | app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 26 | 27 | let rs = RepoShell::new(&app, &row, "code"); |
|
| 27 | 28 | let readme_html = o.readme_html(&rs, &o.tree.target.label); |
|
| 28 | 29 | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html })) |
|
| … | 30 unchanged lines |
||
| 59 | 60 | let (spec, dir) = (r.clone(), path.clone()); |
|
| 60 | 61 | let t = app |
|
| 61 | 62 | .repos |
|
| 62 | - | .read(&repo, move |repo| { |
|
| 63 | - | let target = view::resolve(repo, &spec)?; |
|
| 64 | - | view::tree(repo, target, &dir) |
|
| 63 | + | .read(&repo, move |repo, cache| { |
|
| 64 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 65 | + | view::tree(repo, cache, target, &dir) |
|
| 65 | 66 | }) |
|
| 66 | 67 | .await |
|
| 67 | 68 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| … | 50 unchanged lines |
||
| 118 | 119 | let (spec, p) = (r.clone(), path.clone()); |
|
| 119 | 120 | let (f, blame) = app |
|
| 120 | 121 | .repos |
|
| 121 | - | .read(&repo, move |repo| { |
|
| 122 | - | let target = view::resolve(repo, &spec)?; |
|
| 122 | + | .read(&repo, move |repo, cache| { |
|
| 123 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 123 | 124 | if want_blame { |
|
| 124 | - | let b = view::blame(repo, target, &p, limit)?; |
|
| 125 | + | let b = view::blame(repo, cache, target, &p, limit)?; |
|
| 125 | 126 | Ok((b.file, Some(b.lines))) |
|
| 126 | 127 | } else { |
|
| 127 | - | Ok((view::file(repo, target, &p, limit)?, None)) |
|
| 128 | + | Ok((view::file(repo, cache, target, &p, limit)?, None)) |
|
| 128 | 129 | } |
|
| 129 | 130 | }) |
|
| 130 | 131 | .await |
|
| … | 62 unchanged lines |
||
| 193 | 194 | let spec = r.clone(); |
|
| 194 | 195 | let found = app |
|
| 195 | 196 | .repos |
|
| 196 | - | .read(&repo, move |repo| { |
|
| 197 | - | let target = view::resolve(repo, &spec)?; |
|
| 198 | - | view::last_changes(repo, &target.heads, &paths) |
|
| 197 | + | .read(&repo, move |repo, cache| { |
|
| 198 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 199 | + | view::last_changes(repo, cache, &target.heads, &paths) |
|
| 199 | 200 | }) |
|
| 200 | 201 | .await |
|
| 201 | 202 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| … | 20 unchanged lines |
modified docs/plan.md +1 −2
| … | 25 unchanged lines |
||
| 26 | 26 | ||
| 27 | 27 | ## Notes |
|
| 28 | 28 | ||
| 29 | - | - State caching (spec §16.3) is not started: pages load the state they need per request; the overview loads it once and reuses it. Measured on the `oxygen` repository (88 changes): overview ~0.5 s, tree ~0.14 s, history ~0.13 s, a 2 000-line file ~0.7 s. A per-heads cache is the first performance item for 0.2. |
|
| 30 | - | - `State` holds `Rc` values, so a cross-thread cache needs a `Send` snapshot type in `oxy-core` first. |
|
| 29 | + | - State caching (spec §16.3): `repos::cache::StateCache`, a bounded LRU of loaded states keyed by repository id and heads, shared across threads since `State` became `Send + Sync`. Measured on the `oxygen` repository (90 changes): overview 154 ms cold and under 2 ms warm, history 25 ms, tree under 1 ms. Rendered-page and syntax-highlight caches are still to do. |