OXYGENHUB

Tree views updated in place, and a faster render of edited files e83354cdac22

A tree view is kept up to date as tree ops apply where that is simple
(adding a node under a known directory, removing or moving a file or an
empty directory, changing a mode, all without conflicts) rather than
being worked out again from the whole tree; a test build checks every
such update against a full one. Its entries are shared, so a view is
cheap to clone.

A file graph whose live atoms, with the dead ones contracted away, have
one order only (no ghost, one vertex ready at every step) is rendered
by a Kahn pass over dense indices; conflicts still take the general
algorithm, which it agrees with. A 20 000-line file with 5 000 deleted
lines renders in 5.7 ms rather than 26 ms.

A merge's other-side changes join a history-only import base without
their text unless a built graph needs it.

refs #25, refs #35
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-30 07:03 UTC
Signature
✔ signed by Andy McCall
Hash
e83354cdac22857dd749467f29cdd03e21c92ad1343c1275534671edd9f2b71e
Depends on
206c76cad4bf 2306a4d24779 252e8cb23fbc 598009a75f6c 62794ecc7f74 75167672ebcd 79ab8528ff62 86e1824f2d0b 958fb27dda7c 982864068a59 a87c7b9d563a c3069537d7c5 c5ce0367eddf cc7a00565161 d0d52107b074 e8d38e47e9d7 ff0af1ee299d
Depended on by
36e51aa806c6 930e5f3f1682 05059d434aca f9575918477f 7eac74abc586 dbf06d342e1d
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
29 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/graph.rs +204 −1
…
382 unchanged lines
383 383
384 384
    /// Order the live atoms and locate conflicts.
385 385
    pub fn materialize(&self, deps: &dyn DepOracle) -> Materialized {
386 -
        if let Some(order) = self.as_chain() {
386 +
        if let Some(order) = self.as_chain().or_else(|| self.as_order(deps)) {
387 387
            return Materialized {
388 388
                segments: if order.is_empty() {
389 389
                    vec![]
…
5 unchanged lines
395 395
        self.materialize_general(deps)
396 396
    }
397 397
398 +
    /// The live atoms in order when, with the dead ones contracted away,
399 +
    /// the graph orders them one way only: no deleted atom is a ghost, and
400 +
    /// at every step of the topological sort one vertex is next. A file
401 +
    /// edited without concurrent edits is such a graph, however long its
402 +
    /// history. `None` otherwise, for [`FileGraph::materialize_general`]
403 +
    /// to find the conflicts; where this answers, it gives the same order.
404 +
    fn as_order(&self, deps: &dyn DepOracle) -> Option<Vec<AtomKey>> {
405 +
        // Kept vertices by dense index: Start, End, then the live atoms.
406 +
        let mut index: HashMap<Vertex, u32> =
407 +
            HashMap::with_capacity_and_hasher(self.atoms.len() + 2, Default::default());
408 +
        let mut verts: Vec<Vertex> = Vec::with_capacity(self.atoms.len() + 2);
409 +
        verts.extend([Vertex::Start, Vertex::End]);
410 +
        index.insert(Vertex::Start, 0);
411 +
        index.insert(Vertex::End, 1);
412 +
        for (k, info) in &self.atoms {
413 +
            if info.content.is_none() {
414 +
                continue;
415 +
            }
416 +
            if info.deleted_by.is_empty() {
417 +
                index.insert(Vertex::Atom(*k), verts.len() as u32);
418 +
                verts.push(Vertex::Atom(*k));
419 +
            } else if self.is_ghost(k, info, deps) {
420 +
                return None;
421 +
            }
422 +
        }
423 +
        // Each kept vertex's successors, contracting the others: what a
424 +
        // contracted vertex reaches is worked out once (post-order).
425 +
        let mut reach: HashMap<Vertex, Vec<u32>> = HashMap::default();
426 +
        let contracted = |v: Vertex, reach: &mut HashMap<Vertex, Vec<u32>>| {
427 +
            let mut stack: Vec<(Vertex, bool)> = vec![(v, false)];
428 +
            while let Some((x, done)) = stack.pop() {
429 +
                if reach.contains_key(&x) {
430 +
                    continue;
431 +
                }
432 +
                let next = self.succ.get(&x).map(Edges::as_slice).unwrap_or_default();
433 +
                if !done {
434 +
                    stack.push((x, true));
435 +
                    for s in next {
436 +
                        if !index.contains_key(s) && !reach.contains_key(s) {
437 +
                            stack.push((*s, false));
438 +
                        }
439 +
                    }
440 +
                    continue;
441 +
                }
442 +
                let mut out: Vec<u32> = Vec::new();
443 +
                for s in next {
444 +
                    match index.get(s) {
445 +
                        Some(i) => out.push(*i),
446 +
                        None => out.extend(reach.get(s).into_iter().flatten()),
447 +
                    }
448 +
                }
449 +
                out.sort_unstable();
450 +
                out.dedup();
451 +
                reach.insert(x, out);
452 +
            }
453 +
        };
454 +
        // Successors of vertex `i`: `succ[starts[i]..starts[i + 1]]`.
455 +
        let mut succ: Vec<u32> = Vec::with_capacity(verts.len() * 2);
456 +
        let mut starts: Vec<usize> = Vec::with_capacity(verts.len() + 1);
457 +
        let mut indeg: Vec<u32> = vec![0; verts.len()];
458 +
        for v in &verts {
459 +
            let from = succ.len();
460 +
            starts.push(from);
461 +
            for s in self.succ.get(v).map(Edges::as_slice).unwrap_or_default() {
462 +
                match index.get(s) {
463 +
                    Some(i) => succ.push(*i),
464 +
                    None => {
465 +
                        contracted(*s, &mut reach);
466 +
                        succ.extend(reach.get(s).into_iter().flatten());
467 +
                    }
468 +
                }
469 +
            }
470 +
            if succ.len() - from > 1 {
471 +
                succ[from..].sort_unstable();
472 +
                let mut kept = from;
473 +
                for i in from..succ.len() {
474 +
                    if i == from || succ[i] != succ[kept - 1] {
475 +
                        succ[kept] = succ[i];
476 +
                        kept += 1;
477 +
                    }
478 +
                }
479 +
                succ.truncate(kept);
480 +
            }
481 +
            for &w in &succ[from..] {
482 +
                indeg[w as usize] += 1;
483 +
            }
484 +
        }
485 +
        starts.push(succ.len());
486 +
        // Kahn's algorithm, giving up where two vertices are ready at once.
487 +
        let mut ready: Vec<u32> = (0..verts.len() as u32)
488 +
            .filter(|&i| indeg[i as usize] == 0)
489 +
            .collect();
490 +
        let mut order: Vec<AtomKey> = Vec::with_capacity(verts.len());
491 +
        let mut seen = 0;
492 +
        while let Some(v) = ready.pop() {
493 +
            if !ready.is_empty() {
494 +
                return None;
495 +
            }
496 +
            seen += 1;
497 +
            if let Vertex::Atom(k) = verts[v as usize] {
498 +
                order.push(k);
499 +
            }
500 +
            for &w in &succ[starts[v as usize]..starts[v as usize + 1]] {
501 +
                indeg[w as usize] -= 1;
502 +
                if indeg[w as usize] == 0 {
503 +
                    ready.push(w);
504 +
                }
505 +
            }
506 +
            if ready.len() > 1 {
507 +
                return None;
508 +
            }
509 +
        }
510 +
        (seen == verts.len()).then_some(order)
511 +
    }
512 +
398 513
    /// [`FileGraph::materialize`] for any graph: contraction, then a
399 514
    /// topological order with conflict regions.
400 515
    fn materialize_general(&self, deps: &dyn DepOracle) -> Materialized {
…
401 unchanged lines
802 917
                }
803 918
            }
804 919
            false
920 +
        }
921 +
    }
922 +
923 +
    /// Linear history: each change depends on every earlier one.
924 +
    struct Linear;
925 +
    impl DepOracle for Linear {
926 +
        fn depends_on(&self, a: &ChangeHash, b: &ChangeHash) -> bool {
927 +
            a.0[..2] >= b.0[..2]
928 +
        }
929 +
    }
930 +
931 +
    /// A file of `n` lines edited `revs` times, `per` lines replaced each
932 +
    /// time, as a long-lived file is: most atoms dead, one live chain.
933 +
    fn edited_file(n: usize, revs: usize, per: usize) -> FileGraph {
934 +
        let hn = |i: usize| {
935 +
            let mut b = [0u8; 32];
936 +
            b[..2].copy_from_slice(&(i as u16).to_be_bytes());
937 +
            ObjectRef(b)
938 +
        };
939 +
        let mut g = FileGraph::new();
940 +
        let first: Vec<String> = (0..n).map(|i| format!("line {i}\n")).collect();
941 +
        let first: Vec<&str> = first.iter().map(String::as_str).collect();
942 +
        g.insert(hn(1), 0, Vertex::Start, Vertex::End, &lines(&first));
943 +
        for r in 2..revs + 2 {
944 +
            let order = g.materialize(&Linear).live_atoms();
945 +
            let step = order.len() / per;
946 +
            for j in 0..per {
947 +
                let at = j * step + (r % step.max(1));
948 +
                let k = order[at];
949 +
                g.delete(
950 +
                    hn(r),
951 +
                    &AtomRange {
952 +
                        change: k.0,
953 +
                        from: k.1,
954 +
                        to: k.1 + 1,
955 +
                    },
956 +
                );
957 +
                let after = if at == 0 {
958 +
                    Vertex::Start
959 +
                } else {
960 +
                    Vertex::Atom(order[at - 1])
961 +
                };
962 +
                let before = order
963 +
                    .get(at + 1)
964 +
                    .map(|k| Vertex::Atom(*k))
965 +
                    .unwrap_or(Vertex::End);
966 +
                g.insert(hn(r), j as u32, after, before, &lines(&["new\n"]));
967 +
            }
805 968
        }
969 +
        g
970 +
    }
971 +
972 +
    /// The single-order shortcut gives what the general algorithm gives.
973 +
    #[test]
974 +
    fn the_order_shortcut_agrees_with_the_general_order() {
975 +
        let g = edited_file(500, 10, 20);
976 +
        assert!(g.as_chain().is_none());
977 +
        let fast = g.as_order(&Linear).expect("a single order");
978 +
        assert_eq!(
979 +
            Materialized {
980 +
                segments: vec![Segment::Lines(fast)]
981 +
            },
982 +
            g.materialize_general(&Linear)
983 +
        );
984 +
        // Two changes inserting at one place concurrently: a conflict,
985 +
        // which the shortcut leaves to the general algorithm.
986 +
        let mut g = FileGraph::new();
987 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n"]));
988 +
        let a = Vertex::Atom((h(1), 0));
989 +
        g.insert(h(2), 0, a, Vertex::End, &lines(&["b\n"]));
990 +
        g.insert(h(3), 0, a, Vertex::End, &lines(&["c\n"]));
991 +
        assert!(g.as_order(&NoDeps).is_none());
992 +
        assert!(g.materialize(&NoDeps).is_conflicted());
993 +
    }
994 +
995 +
    /// How long rendering a long-lived file takes (run by hand, in release).
996 +
    #[test]
997 +
    #[ignore]
998 +
    fn bench_materialize_edited_file() {
999 +
        let g = edited_file(20_000, 50, 100);
1000 +
        let t = std::time::Instant::now();
1001 +
        let mut n = 0;
1002 +
        for _ in 0..20 {
1003 +
            n += g.materialize(&Linear).live_atoms().len();
1004 +
        }
1005 +
        eprintln!(
1006 +
            "materialize: {:.2} ms each ({n} atoms)",
1007 +
            t.elapsed().as_secs_f64() * 1000.0 / 20.0
1008 +
        );
806 1009
    }
807 1010
808 1011
    /// The chain shortcut agrees with the general algorithm wherever it
…
479 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +1 −1
…
105 unchanged lines
106 106
        let hex = |b: &[u8]| blake3::hash(b).to_hex().to_string();
107 107
        // Text files to render, attribute files first (the others need them).
108 108
        let mut texts: Vec<(String, NodeId, Mode)> = Vec::new();
109 -
        for (path, e) in &tview.entries {
109 +
        for (path, e) in tview.entries.iter() {
110 110
            if e.kind == NodeKind::Dir || !self.in_scope(path) {
111 111
                continue;
112 112
            }
…
306 unchanged lines
modified crates/oxy-core/src/repo/git.rs +27 −5
…
1048 unchanged lines
1049 1049
                    .contributors
1050 1050
                    .extend(base.live_replaces(&f.node).into_iter().map(|(c, _)| c));
1051 1051
            }
1052 +
            // It shares the base's tree view, which applying the change
1053 +
            // then updates in place rather than copying.
1054 +
            drop(base_snapshot);
1055 +
            drop(entries);
1052 1056
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
1053 1057
            let mut message = gc.message.clone();
1054 1058
            if !message.ends_with('\n') {
…
135 unchanged lines
1190 1194
        let out = git(git_dir, &full)?;
1191 1195
        let mut add = |st: &mut State, h: &ChangeHash| -> Result<()> {
1192 1196
            if st.contains(h) {
1197 +
                return Ok(());
1198 +
            }
1199 +
            if !st.is_history_only() {
1200 +
                let c = self.store.get_change(h)?;
1201 +
                touched.extend(c.edits.iter().map(|e| e.node()));
1202 +
                touched.extend(c.tree_ops.iter().map(|o| o.node()));
1203 +
                st.add_unchecked(c);
1193 1204
                return Ok(());
1205 +
            }
1206 +
            // History only: the text of an edit waits unread unless its
1207 +
            // file's graph is built.
1208 +
            let (skeleton, texts) = self.store.get_change_lazy(h)?;
1209 +
            touched.extend(skeleton.edits.iter().map(|e| e.node()));
1210 +
            touched.extend(skeleton.tree_ops.iter().map(|o| o.node()));
1211 +
            let built = skeleton
1212 +
                .edits
1213 +
                .iter()
1214 +
                .any(|e| !matches!(e, Edit::Replace { .. }) && st.is_built(&e.node()));
1215 +
            if built {
1216 +
                let full = self.store.get_change(h)?;
1217 +
                st.add_known_mixed(*h, full, skeleton, texts);
1218 +
            } else {
1219 +
                st.add_known(*h, skeleton, texts);
1194 1220
            }
1195 -
            let c = self.store.get_change(h)?;
1196 -
            touched.extend(c.edits.iter().map(|e| e.node()));
1197 -
            touched.extend(c.tree_ops.iter().map(|o| o.node()));
1198 -
            st.add_unchecked(c);
1199 1221
            Ok(())
1200 1222
        };
1201 1223
        for sha in out.lines() {
…
322 unchanged lines
1524 1546
            // Import records submodules in `.oxymodules` as well (spec §23),
1525 1547
            // and export leaves it out again.
1526 1548
            let has_submodules = tree.values().any(|(m, _)| m == "160000");
1527 -
            for (path, e) in &view.entries {
1549 +
            for (path, e) in view.entries.iter() {
1528 1550
                if e.kind != NodeKind::Dir
1529 1551
                    && !tree.contains_key(path)
1530 1552
                    && !(has_submodules && path == ".oxymodules")
…
36 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +2 −2
…
329 unchanged lines
330 330
        let tracked: BTreeMap<String, (NodeKind, Option<ObjectRef>)> = {
331 331
            let _t = crate::timing::phase("snapshot: tracked paths");
332 332
            let mut t = BTreeMap::new();
333 -
            for (p, e) in &view.entries {
333 +
            for (p, e) in view.entries.iter() {
334 334
                if e.kind == NodeKind::Dir {
335 335
                    continue;
336 336
                }
…
1253 unchanged lines
1590 1590
    ) -> Self {
1591 1591
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
1592 1592
        dir_nodes.insert(String::new(), NodeId::ROOT);
1593 -
        for (p, e) in &base_snapshot.tree.entries {
1593 +
        for (p, e) in base_snapshot.tree.entries.iter() {
1594 1594
            if e.kind == NodeKind::Dir {
1595 1595
                dir_nodes.insert(p.clone(), e.node);
1596 1596
            }
…
924 unchanged lines
modified crates/oxy-core/src/state.rs +5 −0
…
242 unchanged lines
243 243
        }
244 244
    }
245 245
246 +
    /// Whether `node`'s file graph is built (always, in a full state).
247 +
    pub fn is_built(&self, node: &NodeId) -> bool {
248 +
        self.deferred.is_none() || self.files.contains_key(node)
249 +
    }
250 +
246 251
    /// Whether this state builds file graphs only when asked
247 252
    /// ([`State::new_history_only`]), even if it has none waiting.
248 253
    pub fn is_history_only(&self) -> bool {
…
1751 unchanged lines
modified crates/oxy-core/src/tree.rs +266 −7
…
32 unchanged lines
33 33
    /// apply, so the view walks what is live rather than all history.
34 34
    live_placements_of: HashMap<NodeId, BTreeSet<OpKey>>,
35 35
    live_modes_of: HashMap<NodeId, BTreeSet<OpKey>>,
36 -
    /// The view, once computed; cleared by every change to the graph.
37 -
    view_cache: std::sync::OnceLock<TreeView>,
36 +
    /// The view, once computed, with the path of each directory in it;
37 +
    /// kept up to date as ops apply when that is simple (see
38 +
    /// `update_view`), cleared otherwise.
39 +
    view_cache: std::sync::OnceLock<CachedView>,
38 40
}
39 41
40 42
#[derive(Clone, Debug, PartialEq, Eq)]
…
28 unchanged lines
69 71
    pub placement: OpKey,
70 72
    /// The live mode atom (for building `SetMode`).
71 73
    pub mode_atom: Option<OpKey>,
74 +
}
75 +
76 +
#[derive(Clone, Debug, Default)]
77 +
struct CachedView {
78 +
    view: TreeView,
79 +
    dirs: HashMap<NodeId, String>,
72 80
}
73 81
82 +
impl CachedView {
83 +
    fn new(view: TreeView) -> CachedView {
84 +
        let mut dirs: HashMap<NodeId, String> = view
85 +
            .entries
86 +
            .values()
87 +
            .filter(|e| e.kind == NodeKind::Dir)
88 +
            .map(|e| (e.node, e.path.clone()))
89 +
            .collect();
90 +
        dirs.insert(NodeId::ROOT, String::new());
91 +
        CachedView { view, dirs }
92 +
    }
93 +
}
94 +
74 95
#[derive(Clone, Debug, Default, PartialEq, Eq)]
75 96
pub struct TreeView {
76 97
    /// Path → entry, paths use `/` and have no leading slash; conflicted
77 -
    /// entries carry a `.oxy-conflict-<n>` suffix.
78 -
    pub entries: BTreeMap<String, TreeEntry>,
98 +
    /// entries carry a `.oxy-conflict-<n>` suffix. Shared, so a view is
99 +
    /// cheap to clone.
100 +
    pub entries: std::sync::Arc<BTreeMap<String, TreeEntry>>,
79 101
    pub conflicts: Vec<TreeConflict>,
80 102
}
81 103
…
13 unchanged lines
95 117
96 118
    /// Apply tree op `index` of `change`; `OpRef` self references must already be resolved.
97 119
    pub fn apply(&mut self, change: ChangeHash, index: u32, op: &TreeOp) {
98 -
        self.view_cache = std::sync::OnceLock::new();
120 +
        let cached = self.view_cache.take();
121 +
        // Whether the placement or mode atom this op retires was live.
122 +
        let was_live = match op {
123 +
            TreeOp::RemoveNode { placement, .. } => self.placement_live(&op_key(*placement)),
124 +
            TreeOp::Move { old, .. } => self.placement_live(&op_key(*old)),
125 +
            TreeOp::SetMode { old, .. } => self.mode_live(&op_key(*old)),
126 +
            TreeOp::AddNode { .. } => false,
127 +
        };
128 +
        self.apply_to_graph(change, index, op);
129 +
        if let Some(mut c) = cached {
130 +
            if self.update_view(&mut c, (change, index), op, was_live) {
131 +
                #[cfg(test)]
132 +
                assert_eq!(
133 +
                    c.view,
134 +
                    self.compute_view(),
135 +
                    "view kept up to date by {op:?}"
136 +
                );
137 +
                self.view_cache = std::sync::OnceLock::from(c);
138 +
            }
139 +
        }
140 +
    }
141 +
142 +
    fn apply_to_graph(&mut self, change: ChangeHash, index: u32, op: &TreeOp) {
99 143
        let key = (change, index);
100 144
        match op {
101 145
            TreeOp::AddNode {
…
63 unchanged lines
165 209
        }
166 210
    }
167 211
212 +
    /// Bring the cached view `c` up to date with `op` (already applied as
213 +
    /// `key`) where that is simple and the view has no conflicts; false
214 +
    /// when it must be computed again. `was_live`: whether the placement or
215 +
    /// mode atom `op` retires was live before it.
216 +
    fn update_view(&self, c: &mut CachedView, key: OpKey, op: &TreeOp, was_live: bool) -> bool {
217 +
        if !c.view.conflicts.is_empty() {
218 +
            return false;
219 +
        }
220 +
        match op {
221 +
            TreeOp::AddNode { node, .. } => self.view_add(c, *node, key),
222 +
            TreeOp::RemoveNode { node, placement } => {
223 +
                !was_live || self.view_remove(c, *node, op_key(*placement))
224 +
            }
225 +
            TreeOp::Move { node, old, .. } => {
226 +
                (!was_live || self.view_remove(c, *node, op_key(*old)))
227 +
                    && self.view_add(c, *node, key)
228 +
            }
229 +
            TreeOp::SetMode { node, .. } => {
230 +
                let live = self.live_placements(node);
231 +
                match live.as_slice() {
232 +
                    [] => true,
233 +
                    [(k, p)] => {
234 +
                        let Some(path) = c.dirs.get(&p.parent).map(|d| join(d, &p.name)) else {
235 +
                            return false;
236 +
                        };
237 +
                        let Some(mode) = self.entry_mode(node) else {
238 +
                            return false;
239 +
                        };
240 +
                        let entries = std::sync::Arc::make_mut(&mut c.view.entries);
241 +
                        match entries.get_mut(&path) {
242 +
                            Some(e) if e.node == *node && e.placement == *k => {
243 +
                                (e.mode_atom, e.mode) = mode;
244 +
                                true
245 +
                            }
246 +
                            _ => false,
247 +
                        }
248 +
                    }
249 +
                    _ => false,
250 +
                }
251 +
            }
252 +
        }
253 +
    }
254 +
255 +
    /// The view entry for `node`'s placement `key`, just made live.
256 +
    fn view_add(&self, c: &mut CachedView, node: NodeId, key: OpKey) -> bool {
257 +
        if !self.placement_live(&key) {
258 +
            return true;
259 +
        }
260 +
        if self
261 +
            .live_placements_of
262 +
            .get(&node)
263 +
            .is_none_or(|s| s.len() != 1)
264 +
        {
265 +
            return false;
266 +
        }
267 +
        let Some(p) = self.placements.get(&key) else {
268 +
            return false;
269 +
        };
270 +
        let Some(path) = c.dirs.get(&p.parent).map(|d| join(d, &p.name)) else {
271 +
            return false;
272 +
        };
273 +
        let Some((mode_atom, mode)) = self.entry_mode(&node) else {
274 +
            return false;
275 +
        };
276 +
        let kind = self.kind(&node).unwrap_or(NodeKind::File);
277 +
        let entries = std::sync::Arc::make_mut(&mut c.view.entries);
278 +
        if entries.contains_key(&path) || node == NodeId::ROOT {
279 +
            return false;
280 +
        }
281 +
        // Nothing can be placed under a directory that was not in the view
282 +
        // yet: it would have been an orphan, and the view had no conflicts.
283 +
        if kind == NodeKind::Dir {
284 +
            c.dirs.insert(node, path.clone());
285 +
        }
286 +
        entries.insert(
287 +
            path.clone(),
288 +
            TreeEntry {
289 +
                path,
290 +
                node,
291 +
                kind,
292 +
                mode,
293 +
                placement: key,
294 +
                mode_atom,
295 +
            },
296 +
        );
297 +
        true
298 +
    }
299 +
300 +
    /// Drop the view entry of `node`'s placement `key`, just retired.
301 +
    fn view_remove(&self, c: &mut CachedView, node: NodeId, key: OpKey) -> bool {
302 +
        if self.live_placements_of.contains_key(&node) {
303 +
            return false;
304 +
        }
305 +
        let Some(p) = self.placements.get(&key) else {
306 +
            return false;
307 +
        };
308 +
        let Some(path) = c.dirs.get(&p.parent).map(|d| join(d, &p.name)) else {
309 +
            return false;
310 +
        };
311 +
        let entries = std::sync::Arc::make_mut(&mut c.view.entries);
312 +
        match entries.get(&path) {
313 +
            Some(e) if e.node == node && e.placement == key => {}
314 +
            _ => return false,
315 +
        }
316 +
        if entries.get(&path).is_some_and(|e| e.kind == NodeKind::Dir) {
317 +
            let prefix = format!("{path}/");
318 +
            if entries
319 +
                .range(prefix.clone()..)
320 +
                .next()
321 +
                .is_some_and(|(k, _)| k.starts_with(&prefix))
322 +
            {
323 +
                return false;
324 +
            }
325 +
            c.dirs.remove(&node);
326 +
        }
327 +
        entries.remove(&path);
328 +
        true
329 +
    }
330 +
331 +
    /// The mode atom and mode of `node`'s entry, as `compute_view` finds
332 +
    /// them; `None` when its live modes conflict.
333 +
    fn entry_mode(&self, node: &NodeId) -> Option<(Option<OpKey>, Mode)> {
334 +
        let lm = self.live_modes(node);
335 +
        let distinct: BTreeSet<Mode> = lm.iter().map(|(_, m)| *m).collect();
336 +
        if distinct.len() > 1 {
337 +
            return None;
338 +
        }
339 +
        let mode_atom = lm.iter().map(|(k, _)| *k).max();
340 +
        let mode = mode_atom
341 +
            .and_then(|k| self.modes.get(&k).map(|(_, m)| *m))
342 +
            .unwrap_or_default();
343 +
        Some((mode_atom, mode))
344 +
    }
345 +
168 346
    /// Bring placement `k` in the live index up to date.
169 347
    fn index_placement(&mut self, k: OpKey) {
170 348
        let Some(node) = self.placements.get(&k).map(|p| p.node) else {
…
117 unchanged lines
288 466
    /// The materialized tree (spec §7.1): computed once per graph and
289 467
    /// cloned for each caller.
290 468
    pub fn view(&self) -> TreeView {
291 -
        self.view_cache.get_or_init(|| self.compute_view()).clone()
469 +
        self.view_cache
470 +
            .get_or_init(|| CachedView::new(self.compute_view()))
471 +
            .view
472 +
            .clone()
292 473
    }
293 474
294 475
    fn compute_view(&self) -> TreeView {
295 476
        let mut view = TreeView::default();
477 +
        let mut entries: BTreeMap<String, TreeEntry> = BTreeMap::new();
296 478
297 479
        // Live placements grouped by parent.
298 480
        let mut children: HashMap<NodeId, Vec<(OpKey, &Placement)>> = HashMap::new();
…
96 unchanged lines
395 577
                    };
396 578
                    let kind = self.kind(&node).unwrap_or(NodeKind::File);
397 579
                    let (mode_atom, mode) = modes.get(&node).copied().unwrap_or_default();
398 -
                    view.entries.insert(
580 +
                    entries.insert(
399 581
                        path.clone(),
400 582
                        TreeEntry {
401 583
                            path: path.clone(),
…
50 unchanged lines
452 634
                view.conflicts.push(conflict);
453 635
            }
454 636
        }
637 +
        view.entries = std::sync::Arc::new(entries);
455 638
        view
456 639
    }
457 640
}
…
156 unchanged lines
614 797
            g.index_mode(k);
615 798
        }
616 799
        Ok(g)
800 +
    }
801 +
}
802 +
803 +
fn join(dir: &str, name: &str) -> String {
804 +
    if dir.is_empty() {
805 +
        name.to_string()
806 +
    } else {
807 +
        format!("{dir}/{name}")
617 808
    }
618 809
}
619 810
…
27 unchanged lines
647 838
    }
648 839
    fn paths(v: &TreeView) -> Vec<String> {
649 840
        v.entries.keys().cloned().collect()
841 +
    }
842 +
843 +
    /// Each op applied to a graph whose view is cached keeps it up to date
844 +
    /// or drops it: `apply` checks every kept view against a full one.
845 +
    #[test]
846 +
    fn cached_view_follows_ops() {
847 +
        let at = |c: u8, i: u32| OpRef {
848 +
            change: h(c),
849 +
            index: i,
850 +
        };
851 +
        let mut g = TreeGraph::new();
852 +
        let ops = [
853 +
            TreeOp::AddNode {
854 +
                node: nid(1),
855 +
                kind: NodeKind::Dir,
856 +
                mode: Mode::TEXT,
857 +
                parent: NodeId::ROOT,
858 +
                name: "src".into(),
859 +
            },
860 +
            TreeOp::AddNode {
861 +
                node: nid(2),
862 +
                kind: NodeKind::File,
863 +
                mode: Mode::TEXT,
864 +
                parent: nid(1),
865 +
                name: "a.rs".into(),
866 +
            },
867 +
            TreeOp::SetMode {
868 +
                node: nid(2),
869 +
                old: at(1, 1),
870 +
                mode: Mode {
871 +
                    exec: true,
872 +
                    opaque: false,
873 +
                },
874 +
            },
875 +
            TreeOp::Move {
876 +
                node: nid(2),
877 +
                old: at(1, 1),
878 +
                parent: NodeId::ROOT,
879 +
                name: "b.rs".into(),
880 +
            },
881 +
            // Moving an empty directory, then removing it.
882 +
            TreeOp::Move {
883 +
                node: nid(1),
884 +
                old: at(1, 0),
885 +
                parent: NodeId::ROOT,
886 +
                name: "lib".into(),
887 +
            },
888 +
            TreeOp::RemoveNode {
889 +
                node: nid(1),
890 +
                placement: at(1, 4),
891 +
            },
892 +
            // A name taken twice: a conflict, so the view is computed again.
893 +
            TreeOp::AddNode {
894 +
                node: nid(3),
895 +
                kind: NodeKind::File,
896 +
                mode: Mode::TEXT,
897 +
                parent: NodeId::ROOT,
898 +
                name: "b.rs".into(),
899 +
            },
900 +
        ];
901 +
        for (i, op) in ops.iter().enumerate() {
902 +
            let _ = g.view();
903 +
            g.apply(h(1), i as u32, op);
904 +
            assert_eq!(g.view(), g.compute_view());
905 +
        }
906 +
        let v = g.view();
907 +
        assert_eq!(paths(&v), ["b.rs.oxy-conflict-1", "b.rs.oxy-conflict-2"]);
908 +
        assert!(v.entries.values().any(|e| e.mode.exec));
650 909
    }
651 910
652 911
    #[test]
…
292 unchanged lines