OXYGENHUB
# 19bb31ff1f5c oxygenhub / crates / hub / src / repos / view.rs
31.7 KiB · Rust · last changed Milestone 0.2: accounts and access 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Page-ready views of a repository, computed from `oxy-core` alone (hub
2
//! spec §1.4). Nothing here knows about HTTP or templates; every function
3
//! takes an open `Repo` and returns plain data.
4
9ff27da5 Andy McCall ↶ 5
use super::{RepoError, StateCache};
d23464c0 Andy McCall ↶ 6
use oxy_core::hash::{ChangeHash, KeyId};
7
use oxy_core::objects::{Change, NodeKind};
8
use oxy_core::repo::{BlameLine, FileDiff, Repo, SignatureStatus};
9ff27da5 Andy McCall ↶ 9
use oxy_core::state::{FileContent, Snapshot};
d23464c0 Andy McCall ↶ 10
use oxy_core::tree::TreeConflict;
11
use std::collections::{BTreeMap, BTreeSet, HashMap};
12
13
type Result<T> = std::result::Result<T, RepoError>;
14
15
fn fail(msg: impl Into<String>) -> RepoError {
16
    RepoError::Oxy(oxy_core::repo::RepoError::Msg(msg.into()))
17
}
18
19
// ------------------------------------------------------------------ refs
20
21
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22
pub enum RefKind {
23
    Bookmark,
24
    Tag,
25
    Change,
26
}
27
28
/// What a `{ref}` path segment resolved to.
29
#[derive(Clone, Debug)]
30
pub struct RefTarget {
31
    /// The name as given (bookmark, tag) or the full hash.
32
    pub label: String,
33
    pub kind: RefKind,
34
    pub heads: Vec<ChangeHash>,
35
}
36
37
/// Resolve a bookmark name, a tag name, or a change hash prefix.
9ff27da5 Andy McCall ↶ 38
pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result<RefTarget> {
d23464c0 Andy McCall ↶ 39
    let view = repo.view()?;
40
    if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? {
41
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads });
42
    }
43
    if let Some(heads) = repo.tag_heads(spec)? {
44
        return Ok(RefTarget { label: spec.into(), kind: RefKind::Tag, heads });
45
    }
9ff27da5 Andy McCall ↶ 46
    let hash = resolve_change(repo, cache, spec)?;
d23464c0 Andy McCall ↶ 47
    Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] })
48
}
49
50
/// A change by full hash or unique hex prefix, searched over every
51
/// bookmark's state.
9ff27da5 Andy McCall ↶ 52
pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> {
d23464c0 Andy McCall ↶ 53
    let spec = spec.trim_start_matches('#');
54
    if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
55
        return Err(fail(format!("{spec:?} is not a change")));
56
    }
57
    if let Some(h) = ChangeHash::from_hex(spec) {
58
        if repo.store().has(&h) {
59
            return Ok(h);
60
        }
61
    }
62
    let heads = all_heads(repo)?;
9ff27da5 Andy McCall ↶ 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();
d23464c0 Andy McCall ↶ 65
    match matches.len() {
66
        1 => Ok(matches[0]),
67
        0 => Err(fail(format!("no change matches {spec:?}"))),
68
        _ => Err(fail(format!("{spec:?} is ambiguous"))),
69
    }
70
}
71
72
/// The heads of every bookmark, deduplicated.
73
pub fn all_heads(repo: &Repo) -> Result<Vec<ChangeHash>> {
74
    let mut set = BTreeSet::new();
75
    for b in repo.bookmarks()? {
76
        set.extend(b.heads);
77
    }
78
    Ok(set.into_iter().collect())
79
}
80
81
// ---------------------------------------------------------------- authors
82
83
#[derive(Clone, Debug, PartialEq, Eq)]
84
pub struct AuthorView {
85
    pub name: String,
86
    pub email: String,
87
    pub key: String,
88
    /// True when the key is not an identity the store knows.
89
    pub unknown: bool,
19bb31ff Andy McCall ↶ 90
    /// The hub account the key is linked to, filled by the web layer.
91
    pub account: Option<String>,
d23464c0 Andy McCall ↶ 92
}
93
94
pub fn author(repo: &Repo, key: &KeyId, message: &str) -> AuthorView {
95
    if key.is_zero() {
96
        // Imported from Git: the trailer carries the author.
97
        let line = message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).unwrap_or("unknown");
98
        let (name, email) = match line.split_once('<') {
99
            Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').trim().to_string()),
100
            None => (line.trim().to_string(), String::new()),
101
        };
19bb31ff Andy McCall ↶ 102
        return AuthorView { name, email, key: String::new(), unknown: true, account: None };
d23464c0 Andy McCall ↶ 103
    }
104
    match repo.store().get_identity(key) {
19bb31ff Andy McCall ↶ 105
        Ok(id) => AuthorView { name: id.name, email: id.email, key: key.to_hex(), unknown: false, account: None },
106
        Err(_) => AuthorView { name: key.short(12), email: String::new(), key: key.to_hex(), unknown: true, account: None },
d23464c0 Andy McCall ↶ 107
    }
108
}
109
110
// ---------------------------------------------------------------- changes
111
112
#[derive(Clone, Debug, PartialEq, Eq)]
113
pub enum SigView {
114
    Unsigned,
115
    Valid { key: String, name: String, sealed: bool, revoked: bool },
116
    Invalid(String),
117
}
118
119
impl SigView {
120
    pub fn glyph(&self) -> &'static str {
121
        match self {
122
            SigView::Unsigned => "○",
123
            SigView::Valid { revoked: true, .. } => "⊘",
124
            SigView::Valid { sealed: true, .. } => "◈",
125
            SigView::Valid { .. } => "✔",
126
            SigView::Invalid(_) => "✘",
127
        }
128
    }
129
130
    pub fn class(&self) -> &'static str {
131
        match self {
132
            SigView::Unsigned => "sig-unsigned",
133
            SigView::Valid { revoked: true, .. } => "sig-revoked",
134
            SigView::Valid { .. } => "sig-valid",
135
            SigView::Invalid(_) => "sig-invalid",
136
        }
137
    }
138
139
    pub fn title(&self) -> String {
140
        match self {
141
            SigView::Unsigned => "unsigned".into(),
142
            SigView::Valid { name, sealed: true, revoked: false, .. } => format!("sealed by {name}"),
143
            SigView::Valid { name, revoked: true, .. } => format!("signed by {name}, key revoked"),
144
            SigView::Valid { name, .. } => format!("signed by {name}"),
145
            SigView::Invalid(why) => format!("INVALID: {why}"),
146
        }
147
    }
148
}
149
150
pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView {
151
    match repo.verify_change(hash) {
152
        Ok(SignatureStatus::Unsigned) => SigView::Unsigned,
153
        Ok(SignatureStatus::Valid { key, name, sealed, revoked, .. }) => SigView::Valid { key: key.to_hex(), name, sealed, revoked },
154
        Ok(SignatureStatus::Invalid(why)) => SigView::Invalid(why),
155
        Err(e) => SigView::Invalid(e.to_string()),
156
    }
157
}
158
159
/// One change as history rows and change pages show it.
160
#[derive(Clone, Debug)]
161
pub struct ChangeView {
162
    pub hash: String,
163
    pub short: String,
164
    pub title: String,
165
    pub body: String,
166
    pub author: AuthorView,
167
    pub when_ms: u64,
168
    pub deps: Vec<String>,
169
    pub head: bool,
170
    pub resolution: bool,
171
    pub signature: SigView,
172
    /// Bookmarks and tags whose state has this change as a head.
173
    pub labels: Vec<Label>,
174
}
175
176
#[derive(Clone, Debug, PartialEq, Eq)]
177
pub struct Label {
178
    pub name: String,
179
    pub kind: RefKind,
180
}
181
182
pub fn split_message(message: &str) -> (String, String) {
183
    let mut lines = message.lines();
184
    let title = lines.next().unwrap_or("").trim().to_string();
185
    let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
186
    (title, body)
187
}
188
189
/// Every bookmark and tag head, for labelling rows.
190
pub fn head_labels(repo: &Repo) -> Result<HashMap<ChangeHash, Vec<Label>>> {
191
    let mut out: HashMap<ChangeHash, Vec<Label>> = HashMap::new();
192
    for b in repo.bookmarks()? {
193
        for h in b.heads {
194
            out.entry(h).or_default().push(Label { name: b.name.clone(), kind: RefKind::Bookmark });
195
        }
196
    }
197
    for t in repo.tags()? {
198
        for h in t.heads {
199
            out.entry(h).or_default().push(Label { name: t.name.clone(), kind: RefKind::Tag });
200
        }
201
    }
202
    Ok(out)
203
}
204
205
fn change_view(
206
    repo: &Repo,
207
    hash: &ChangeHash,
208
    meta: &oxy_core::state::ChangeMeta,
209
    head: bool,
210
    labels: &HashMap<ChangeHash, Vec<Label>>,
211
) -> ChangeView {
212
    let (title, body) = split_message(&meta.message);
213
    ChangeView {
214
        hash: hash.to_hex(),
215
        short: hash.short(12),
216
        title,
217
        body,
218
        author: author(repo, &meta.author, &meta.message),
219
        when_ms: meta.hlc.wall_ms,
220
        deps: meta.deps.iter().map(|d| d.to_hex()).collect(),
221
        head,
222
        resolution: meta.kind == oxy_core::objects::ChangeKind::Resolution,
223
        signature: signature(repo, hash),
224
        labels: labels.get(hash).cloned().unwrap_or_default(),
225
    }
226
}
227
228
// ---------------------------------------------------------------- history
229
230
#[derive(Clone, Debug)]
231
pub struct History {
232
    pub target: RefTarget,
233
    pub entries: Vec<ChangeView>,
234
    pub total: usize,
235
    pub offset: usize,
236
    pub has_more: bool,
237
    pub path: Option<String>,
238
}
239
240
/// A window of the history of a state, newest first.
9ff27da5 Andy McCall ↶ 241
pub fn history(repo: &Repo, cache: &StateCache, target: RefTarget, path: Option<&str>, offset: usize, limit: usize) -> Result<History> {
242
    let loaded = cache.load(repo, &target.heads)?;
243
    let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect();
244
    let mut entries: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> = match path {
245
        Some(p) => {
246
            let Some(node) = loaded.snapshot.files.get(p).map(|f| f.node) else { return Err(fail(format!("{p} is not in that state"))) };
247
            loaded
248
                .state
249
                .changes()
250
                .filter(|(h, _)| {
251
                    repo.store()
252
                        .get_change(h)
253
                        .map(|c| c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node))
254
                        .unwrap_or(false)
255
                })
256
                .map(|(h, m)| (*h, m.clone()))
257
                .collect()
258
        }
259
        None => loaded.state.changes().map(|(h, m)| (*h, m.clone())).collect(),
d23464c0 Andy McCall ↶ 260
    };
9ff27da5 Andy McCall ↶ 261
    entries.sort_by(|a, b| b.1.hlc.cmp(&a.1.hlc).then(b.0.cmp(&a.0)));
d23464c0 Andy McCall ↶ 262
    let total = entries.len();
263
    let labels = head_labels(repo)?;
264
    let window: Vec<ChangeView> =
9ff27da5 Andy McCall ↶ 265
        entries.iter().skip(offset).take(limit).map(|(h, m)| change_view(repo, h, m, head_set.contains(h), &labels)).collect();
d23464c0 Andy McCall ↶ 266
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) })
267
}
268
269
// ----------------------------------------------------------------- change
270
271
#[derive(Clone, Debug)]
272
pub struct ChangeDetail {
273
    pub view: ChangeView,
274
    pub dependents: Vec<ChangeView>,
275
    pub deps: Vec<ChangeView>,
276
    pub files: Vec<FileDiff>,
277
    /// Base content of each modified file, for context lines.
278
    pub bases: BTreeMap<String, Vec<u8>>,
279
    pub in_bookmarks: Vec<String>,
280
    pub in_tags: Vec<String>,
281
    pub atoms: usize,
282
    pub tree_ops: usize,
283
}
284
9ff27da5 Andy McCall ↶ 285
pub fn change(repo: &Repo, cache: &StateCache, hash: &ChangeHash) -> Result<ChangeDetail> {
d23464c0 Andy McCall ↶ 286
    let c: Change = repo.store().get_change(hash)?;
287
    let labels = head_labels(repo)?;
288
    let heads = all_heads(repo)?;
9ff27da5 Andy McCall ↶ 289
    let loaded = cache.load(repo, &heads)?;
290
    let state = &loaded.state;
d23464c0 Andy McCall ↶ 291
    let is_head = state.heads().contains(hash);
292
    let meta = oxy_core::state::ChangeMeta::of(&c);
293
    let view = change_view(repo, hash, &meta, is_head, &labels);
294
    let deps = c.deps.iter().filter_map(|d| state.get(d).map(|m| change_view(repo, d, m, state.heads().contains(d), &labels))).collect();
295
    let mut dependents: Vec<ChangeView> = state
296
        .changes()
297
        .filter(|(_, m)| m.deps.contains(hash))
298
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels))
299
        .collect();
300
    dependents.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
9ff27da5 Andy McCall ↶ 301
    let base = cache.load(repo, &c.deps)?;
302
    let files = crate::render::diff::change_files(&c, &base.state, &base.snapshot);
303
    let base_snapshot = &base.snapshot;
d23464c0 Andy McCall ↶ 304
    let mut bases = BTreeMap::new();
305
    for f in &files {
306
        let old = f.from.as_deref().unwrap_or(&f.path);
307
        if let Some(FileContent::Text { bytes, .. }) = base_snapshot.files.get(old).map(|s| &s.content) {
308
            bases.insert(f.path.clone(), bytes.clone());
309
        }
310
    }
311
    let mut in_bookmarks = Vec::new();
312
    for b in repo.bookmarks()? {
9ff27da5 Andy McCall ↶ 313
        if cache.load(repo, &b.heads)?.state.contains(hash) {
d23464c0 Andy McCall ↶ 314
            in_bookmarks.push(b.name);
315
        }
316
    }
317
    let mut in_tags = Vec::new();
318
    for t in repo.tags()? {
9ff27da5 Andy McCall ↶ 319
        if cache.load(repo, &t.heads)?.state.contains(hash) {
d23464c0 Andy McCall ↶ 320
            in_tags.push(t.name);
321
        }
322
    }
323
    Ok(ChangeDetail { view, dependents, deps, files, bases, in_bookmarks, in_tags, atoms: c.edits.len(), tree_ops: c.tree_ops.len() })
324
}
325
326
// ------------------------------------------------------------------- tree
327
328
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
329
pub enum EntryKind {
330
    Dir,
331
    File,
332
    Symlink,
333
    Subrepo,
334
}
335
336
#[derive(Clone, Debug)]
337
pub struct Entry {
338
    pub name: String,
339
    pub path: String,
340
    pub kind: EntryKind,
341
    pub exec: bool,
342
    pub size: Option<u64>,
343
    pub conflicted: bool,
344
}
345
346
#[derive(Clone, Debug)]
347
pub struct Tree {
348
    pub target: RefTarget,
349
    pub path: String,
350
    pub entries: Vec<Entry>,
351
    /// `(name, path)` from the root down to this directory.
352
    pub crumbs: Vec<(String, String)>,
353
    pub readme: Option<Readme>,
354
}
355
356
#[derive(Clone, Debug)]
357
pub struct Readme {
358
    pub path: String,
359
    pub text: String,
360
}
361
362
pub fn crumbs(path: &str) -> Vec<(String, String)> {
363
    let mut out = Vec::new();
364
    let mut so_far = String::new();
365
    for part in path.split('/').filter(|p| !p.is_empty()) {
366
        if !so_far.is_empty() {
367
            so_far.push('/');
368
        }
369
        so_far.push_str(part);
370
        out.push((part.to_string(), so_far.clone()));
371
    }
372
    out
373
}
374
375
fn entry_of(path: &str, name: &str, f: &oxy_core::state::FileState) -> Entry {
376
    let kind = match f.kind {
377
        NodeKind::Dir => EntryKind::Dir,
378
        NodeKind::File => EntryKind::File,
379
        NodeKind::Symlink => EntryKind::Symlink,
380
        NodeKind::Subrepo => EntryKind::Subrepo,
381
    };
382
    let size = match &f.content {
383
        FileContent::Text { bytes, .. } => Some(bytes.len() as u64),
384
        _ => None,
385
    };
386
    Entry { name: name.to_string(), path: path.to_string(), kind, exec: f.mode.exec, size, conflicted: f.is_conflicted() }
387
}
388
389
/// The entries directly under `dir` (`""` for the root).
9ff27da5 Andy McCall ↶ 390
pub fn tree(repo: &Repo, cache: &StateCache, target: RefTarget, dir: &str) -> Result<Tree> {
391
    let loaded = cache.load(repo, &target.heads)?;
392
    tree_of(repo, target, &loaded.snapshot, dir)
d23464c0 Andy McCall ↶ 393
}
394
395
pub fn tree_of(_repo: &Repo, target: RefTarget, snapshot: &Snapshot, dir: &str) -> Result<Tree> {
396
    let dir = dir.trim_matches('/');
397
    if !dir.is_empty() {
398
        match snapshot.files.get(dir) {
399
            Some(f) if f.kind == NodeKind::Dir => {}
400
            _ => return Err(RepoError::NotFound),
401
        }
402
    }
403
    let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") };
404
    let mut entries: Vec<Entry> = snapshot
405
        .files
406
        .iter()
407
        .filter(|(p, _)| p.starts_with(&prefix) && !p[prefix.len()..].contains('/'))
408
        .map(|(p, f)| entry_of(p, &p[prefix.len()..], f))
409
        .collect();
410
    entries.sort_by(|a, b| {
411
        (a.kind != EntryKind::Dir).cmp(&(b.kind != EntryKind::Dir)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
412
    });
413
    let readme = entries
414
        .iter()
415
        .find(|e| {
416
            e.kind == EntryKind::File && matches!(e.name.to_lowercase().as_str(), "readme.md" | "readme" | "readme.txt" | "readme.markdown")
417
        })
418
        .and_then(|e| match &snapshot.files.get(&e.path)?.content {
419
            FileContent::Text { bytes, .. } => Some(Readme { path: e.path.clone(), text: String::from_utf8_lossy(bytes).into_owned() }),
420
            _ => None,
421
        });
422
    Ok(Tree { target, path: dir.to_string(), entries, crumbs: crumbs(dir), readme })
423
}
424
425
// ------------------------------------------------------------------- file
426
427
#[derive(Clone, Debug)]
428
pub enum FileBody {
429
    Text(String),
430
    Binary,
431
    TooLarge,
432
}
433
434
#[derive(Clone, Debug)]
435
pub struct FileView {
436
    pub target: RefTarget,
437
    pub path: String,
438
    pub name: String,
439
    pub crumbs: Vec<(String, String)>,
440
    pub kind: EntryKind,
441
    pub exec: bool,
442
    pub size: u64,
443
    pub body: FileBody,
444
    pub conflicted: bool,
445
    /// Raw bytes, for the `?plain=1` response and images.
446
    pub bytes: Vec<u8>,
447
    pub last_change: Option<ChangeView>,
448
}
449
9ff27da5 Andy McCall ↶ 450
pub fn file(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> {
451
    let loaded = cache.load(repo, &target.heads)?;
452
    let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 453
    let path = path.trim_matches('/');
454
    let Some(f) = snapshot.files.get(path) else { return Err(RepoError::NotFound) };
455
    let (kind, bytes) = match (&f.kind, &f.content) {
456
        (NodeKind::Dir, _) => return Err(RepoError::NotFound),
457
        (NodeKind::Symlink, FileContent::Text { bytes, .. }) => (EntryKind::Symlink, bytes.clone()),
458
        (NodeKind::Subrepo, FileContent::Text { bytes, .. }) => (EntryKind::Subrepo, bytes.clone()),
459
        (_, FileContent::Text { bytes, .. }) => (EntryKind::File, bytes.clone()),
460
        (_, FileContent::Opaque { blobs }) => {
461
            let bytes = match blobs.first() {
462
                Some(b) => repo.read_blob(b)?,
463
                None => Vec::new(),
464
            };
465
            (EntryKind::File, bytes)
466
        }
467
        (_, FileContent::Dir) => return Err(RepoError::NotFound),
468
    };
469
    let size = bytes.len() as u64;
470
    let body = if size > render_limit {
471
        FileBody::TooLarge
472
    } else if is_binary(&bytes) {
473
        FileBody::Binary
474
    } else {
475
        FileBody::Text(String::from_utf8_lossy(&bytes).into_owned())
476
    };
477
    let labels = head_labels(repo)?;
478
    let last_change = repo
479
        .log_for_path_in(path, &target.heads)
480
        .ok()
481
        .and_then(|l| l.into_iter().next())
482
        .map(|e| change_view(repo, &e.hash, &e.change, e.is_head, &labels));
483
    let name = path.rsplit('/').next().unwrap_or(path).to_string();
484
    Ok(FileView {
485
        target,
486
        path: path.to_string(),
487
        name,
488
        crumbs: crumbs(path),
489
        kind,
490
        exec: f.mode.exec,
491
        size,
492
        body,
493
        conflicted: f.is_conflicted(),
494
        bytes,
495
        last_change,
496
    })
497
}
498
499
pub fn is_binary(bytes: &[u8]) -> bool {
500
    let head = &bytes[..bytes.len().min(8192)];
501
    head.contains(&0)
502
        || std::str::from_utf8(head).is_err()
503
            && head.iter().filter(|b| **b < 0x20 && !matches!(**b, b'\n' | b'\r' | b'\t')).count() > head.len() / 20
504
}
505
506
/// Last change per entry of a directory, for tree rows (fetched lazily).
507
/// A directory's last change is the newest change touching anything
508
/// beneath it, as GitHub shows it.
9ff27da5 Andy McCall ↶ 509
pub fn last_changes(repo: &Repo, cache: &StateCache, heads: &[ChangeHash], paths: &[String]) -> Result<Vec<(String, ChangeView)>> {
d23464c0 Andy McCall ↶ 510
    let labels = head_labels(repo)?;
9ff27da5 Andy McCall ↶ 511
    let loaded = cache.load(repo, heads)?;
512
    let state = &loaded.state;
513
    let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 514
    // Newest change touching each node, from one pass over the log.
515
    let mut newest: HashMap<oxy_core::ids::NodeId, (ChangeHash, u64)> = HashMap::new();
516
    for e in repo.log_state(heads)? {
517
        let Ok(c) = repo.store().get_change(&e.hash) else { continue };
518
        let when = e.change.hlc.wall_ms;
519
        for node in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|x| x.node())) {
520
            newest.entry(node).or_insert((e.hash, when));
521
        }
522
    }
523
    let mut out = Vec::new();
524
    for p in paths {
525
        let Some(f) = snapshot.files.get(p) else { continue };
526
        let best = if f.kind == NodeKind::Dir {
527
            let prefix = format!("{p}/");
528
            snapshot
529
                .files
530
                .iter()
531
                .filter(|(q, _)| q.starts_with(&prefix))
532
                .filter_map(|(_, g)| newest.get(&g.node))
533
                .chain(newest.get(&f.node))
534
                .max_by_key(|(_, when)| *when)
535
                .copied()
536
        } else {
537
            newest.get(&f.node).copied()
538
        };
539
        if let Some((hash, _)) = best {
540
            if let Some(m) = state.get(&hash) {
541
                out.push((p.clone(), change_view(repo, &hash, m, state.heads().contains(&hash), &labels)));
542
            }
543
        }
544
    }
545
    Ok(out)
546
}
547
548
// ------------------------------------------------------------------ blame
549
550
#[derive(Clone, Debug)]
551
pub struct Blame {
552
    pub file: FileView,
553
    pub lines: Vec<BlameLine>,
554
}
555
9ff27da5 Andy McCall ↶ 556
pub fn blame(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> {
d23464c0 Andy McCall ↶ 557
    let heads = target.heads.clone();
9ff27da5 Andy McCall ↶ 558
    let file = file(repo, cache, target, path, render_limit)?;
d23464c0 Andy McCall ↶ 559
    let lines = repo.blame_in(path, &heads)?;
560
    Ok(Blame { file, lines })
561
}
562
563
// -------------------------------------------------------------- overview
564
565
#[derive(Clone, Debug)]
566
pub struct BookmarkView {
567
    pub name: String,
568
    pub heads: Vec<String>,
569
    pub changes: usize,
570
    pub default: bool,
571
    pub conflicted: bool,
572
    pub latest_ms: u64,
573
}
574
575
#[derive(Clone, Debug)]
576
pub struct TagView {
577
    pub name: String,
578
    pub heads: Vec<String>,
579
    pub message: String,
580
    pub signer: Option<String>,
581
}
582
583
#[derive(Clone, Debug)]
584
pub struct Overview {
585
    pub tree: Tree,
586
    pub changes: usize,
587
    pub heads: usize,
588
    pub bookmarks: Vec<BookmarkView>,
589
    pub tags: Vec<TagView>,
590
    pub latest: Option<ChangeView>,
591
    pub conflicted: bool,
592
    pub license: Option<String>,
593
    pub policy: PolicyBadge,
594
}
595
596
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
597
pub enum PolicyBadge {
598
    None,
599
    Active,
600
    Expired,
601
}
602
9ff27da5 Andy McCall ↶ 603
pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> {
d23464c0 Andy McCall ↶ 604
    let mut out = Vec::new();
605
    for b in repo.bookmarks()? {
9ff27da5 Andy McCall ↶ 606
        let loaded = cache.load(repo, &b.heads)?;
607
        let state = &loaded.state;
d23464c0 Andy McCall ↶ 608
        let latest_ms = state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0);
9ff27da5 Andy McCall ↶ 609
        let conflicted = loaded.snapshot.is_conflicted();
d23464c0 Andy McCall ↶ 610
        out.push(BookmarkView {
611
            name: b.name.clone(),
612
            heads: b.heads.iter().map(|h| h.to_hex()).collect(),
613
            changes: state.len(),
614
            default: b.name == default,
615
            conflicted,
616
            latest_ms,
617
        });
618
    }
619
    out.sort_by(|a, b| b.default.cmp(&a.default).then_with(|| a.name.cmp(&b.name)));
620
    Ok(out)
621
}
622
623
pub fn tags(repo: &Repo) -> Result<Vec<TagView>> {
624
    let mut out: Vec<TagView> = repo
625
        .tags()?
626
        .into_iter()
627
        .map(|t| TagView { name: t.name, heads: t.heads.iter().map(|h| h.to_hex()).collect(), message: t.message, signer: t.signer })
628
        .collect();
629
    out.sort_by(|a, b| b.name.cmp(&a.name));
630
    Ok(out)
631
}
632
633
pub fn policy_badge(repo: &Repo) -> PolicyBadge {
634
    match repo.current_policy() {
635
        Ok(Some((_, sp))) => {
636
            if sp.policy.expires != 0 && (sp.policy.expires as i64) < crate::db::now_ms() {
637
                PolicyBadge::Expired
638
            } else {
639
                PolicyBadge::Active
640
            }
641
        }
642
        _ => PolicyBadge::None,
643
    }
644
}
645
9ff27da5 Andy McCall ↶ 646
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str) -> Result<Overview> {
647
    let target = match resolve(repo, cache, default_bookmark) {
d23464c0 Andy McCall ↶ 648
        Ok(t) => t,
649
        Err(_) => RefTarget { label: default_bookmark.into(), kind: RefKind::Bookmark, heads: vec![] },
650
    };
651
    let heads = target.heads.len();
652
    // One load of the default state serves the counts, the latest change
653
    // and the tree (hub spec §16.3).
9ff27da5 Andy McCall ↶ 654
    let loaded = cache.load(repo, &target.heads)?;
655
    let state = &loaded.state;
656
    let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 657
    let changes = state.len();
658
    let conflicted = snapshot.is_conflicted();
659
    let labels = head_labels(repo)?;
660
    let latest = state
661
        .changes()
662
        .max_by(|(ha, a), (hb, b)| a.hlc.cmp(&b.hlc).then(ha.cmp(hb)))
663
        .map(|(h, m)| change_view(repo, h, m, state.heads().contains(h), &labels));
9ff27da5 Andy McCall ↶ 664
    let tree = tree_of(repo, target, snapshot, "")?;
d23464c0 Andy McCall ↶ 665
    let license = tree
666
        .entries
667
        .iter()
668
        .find(|e| matches!(e.name.to_lowercase().as_str(), "license" | "license.md" | "license.txt" | "licence" | "copying"))
669
        .map(|e| e.path.clone());
670
    Ok(Overview {
671
        tree,
672
        changes,
673
        heads,
9ff27da5 Andy McCall ↶ 674
        bookmarks: bookmarks(repo, cache, default_bookmark)?,
d23464c0 Andy McCall ↶ 675
        tags: tags(repo)?,
676
        latest,
677
        conflicted,
678
        license,
679
        policy: policy_badge(repo),
680
    })
681
}
682
683
// -------------------------------------------------------------- compare
684
685
#[derive(Clone, Debug)]
686
pub struct Compare {
687
    pub base: RefTarget,
688
    pub head: RefTarget,
689
    /// Changes in `head`'s state not in `base`'s.
690
    pub added: Vec<ChangeView>,
691
    /// Changes in `base`'s state not in `head`'s.
692
    pub missing: usize,
693
    pub files: Vec<FileDiff>,
694
    pub bases: BTreeMap<String, Vec<u8>>,
695
}
696
9ff27da5 Andy McCall ↶ 697
pub fn compare(repo: &Repo, cache: &StateCache, base: RefTarget, head: RefTarget) -> Result<Compare> {
698
    let base_loaded = cache.load(repo, &base.heads)?;
699
    let head_loaded = cache.load(repo, &head.heads)?;
700
    let base_state = &base_loaded.state;
701
    let head_state = &head_loaded.state;
d23464c0 Andy McCall ↶ 702
    let labels = head_labels(repo)?;
703
    let mut added: Vec<ChangeView> = head_state
704
        .changes()
705
        .filter(|(h, _)| !base_state.contains(h))
706
        .map(|(h, m)| change_view(repo, h, m, head_state.heads().contains(h), &labels))
707
        .collect();
708
    added.sort_by_key(|c| std::cmp::Reverse(c.when_ms));
709
    let missing = base_state.changes().filter(|(h, _)| !head_state.contains(h)).count();
9ff27da5 Andy McCall ↶ 710
    let (files, bases) = crate::render::diff::snapshot_diff(&base_loaded.snapshot, &head_loaded.snapshot);
d23464c0 Andy McCall ↶ 711
    Ok(Compare { base, head, added, missing, files, bases })
712
}
713
714
// ------------------------------------------------------------ conflicts
715
716
#[derive(Clone, Debug)]
717
pub struct ConflictView {
718
    pub bookmark: String,
719
    pub path: String,
720
    pub kind: String,
721
    /// The file rendered with `oxy` conflict markers, when a text file.
722
    pub marked: Option<String>,
723
    pub contributors: Vec<ChangeView>,
724
}
725
9ff27da5 Andy McCall ↶ 726
pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> {
d23464c0 Andy McCall ↶ 727
    let labels = head_labels(repo)?;
728
    let mut out = Vec::new();
729
    for b in repo.bookmarks()? {
9ff27da5 Andy McCall ↶ 730
        let loaded = cache.load(repo, &b.heads)?;
731
        let state = &loaded.state;
732
        let snapshot = &loaded.snapshot;
d23464c0 Andy McCall ↶ 733
        for c in &snapshot.tree.conflicts {
9ff27da5 Andy McCall ↶ 734
            let (path, kind) = describe_tree_conflict(snapshot, c);
d23464c0 Andy McCall ↶ 735
            out.push(ConflictView { bookmark: b.name.clone(), path, kind, marked: None, contributors: vec![] });
736
        }
737
        for (path, f) in &snapshot.files {
738
            if !f.is_conflicted() {
739
                continue;
740
            }
741
            let (kind, marked, contributors) = match &f.content {
742
                FileContent::Text { .. } => {
743
                    let (bytes, _) = state.render_file(&f.node);
744
                    let regions = state.conflict_regions(&f.node);
745
                    let contributors: Vec<ChangeView> = regions
746
                        .contributors
747
                        .iter()
748
                        .filter_map(|h| state.get(h).map(|m| change_view(repo, h, m, state.heads().contains(h), &labels)))
749
                        .collect();
750
                    ("lines".to_string(), Some(String::from_utf8_lossy(&bytes).into_owned()), contributors)
751
                }
752
                FileContent::Opaque { blobs } => (format!("replace ({} candidates)", blobs.len()), None, vec![]),
753
                FileContent::Dir => continue,
754
            };
755
            out.push(ConflictView { bookmark: b.name.clone(), path: path.clone(), kind, marked, contributors });
756
        }
757
    }
758
    Ok(out)
759
}
760
761
fn describe_tree_conflict(snapshot: &Snapshot, c: &TreeConflict) -> (String, String) {
762
    let by_node = snapshot.tree.by_node();
763
    let path_of = |n: &oxy_core::ids::NodeId| by_node.get(n).map(|e| e.path.clone()).unwrap_or_else(|| n.short(8));
764
    match c {
765
        TreeConflict::Name { name, nodes, .. } => (name.clone(), format!("name: {} entries want this name", nodes.len())),
766
        TreeConflict::MultiPlacement { node, placements } => (path_of(node), format!("placement: {} places", placements.len())),
767
        TreeConflict::Orphan { node, .. } => (path_of(node), "orphan: parent directory was removed".into()),
768
        TreeConflict::Cycle { node } => (path_of(node), "cycle: directory moved into itself".into()),
769
        other => (String::new(), format!("{other:?}")),
770
    }
771
}
772
773
// ------------------------------------------------------------------ trust
774
775
#[derive(Clone, Debug)]
776
pub struct KeyView {
777
    pub key: String,
778
    pub short: String,
779
    pub name: String,
780
    pub email: String,
781
}
782
783
#[derive(Clone, Debug)]
784
pub struct RuleView {
785
    pub pattern: String,
786
    pub keys: Vec<KeyView>,
787
    pub threshold: u32,
788
    pub require: String,
789
    pub allow_conflicts: bool,
790
}
791
792
#[derive(Clone, Debug)]
793
pub struct PolicyView {
794
    pub serial: u64,
795
    pub expires_ms: u64,
796
    pub expired: bool,
797
    pub roots: Vec<KeyView>,
798
    pub root_threshold: u32,
799
    pub rules: Vec<RuleView>,
800
    pub revoked: Vec<KeyView>,
801
    pub signatures: usize,
802
    pub history: usize,
803
}
804
805
#[derive(Clone, Debug, Default)]
806
pub struct Verification {
807
    pub total: usize,
808
    pub signed: usize,
809
    pub sealed: usize,
810
    pub unsigned: usize,
811
    pub revoked: usize,
812
    pub invalid: Vec<(String, String)>,
813
}
814
815
#[derive(Clone, Debug)]
816
pub struct Trust {
817
    pub policy: Option<PolicyView>,
818
    pub verification: Verification,
819
    pub hub_key: String,
820
    pub hub_delegated_by: Vec<KeyView>,
821
}
822
823
fn key_view(repo: &Repo, ids: &[oxy_core::identity::Identity], key: &KeyId) -> KeyView {
824
    let found = ids.iter().find(|i| i.hash() == *key).cloned().or_else(|| repo.store().get_identity(key).ok());
825
    match found {
826
        Some(i) => KeyView { key: key.to_hex(), short: key.short(12), name: i.name, email: i.email },
827
        None => KeyView { key: key.to_hex(), short: key.short(12), name: String::new(), email: String::new() },
828
    }
829
}
830
9ff27da5 Andy McCall ↶ 831
pub fn verification(repo: &Repo, cache: &StateCache) -> Result<Verification> {
d23464c0 Andy McCall ↶ 832
    let heads = all_heads(repo)?;
9ff27da5 Andy McCall ↶ 833
    let loaded = cache.load(repo, &heads)?;
834
    let state = &loaded.state;
d23464c0 Andy McCall ↶ 835
    let mut v = Verification::default();
836
    for (h, _) in state.changes() {
837
        v.total += 1;
838
        match signature(repo, h) {
839
            SigView::Unsigned => v.unsigned += 1,
840
            SigView::Valid { sealed, revoked, .. } => {
841
                if revoked {
842
                    v.revoked += 1;
843
                }
844
                if sealed {
845
                    v.sealed += 1
846
                } else {
847
                    v.signed += 1
848
                }
849
            }
850
            SigView::Invalid(why) => v.invalid.push((h.to_hex(), why)),
851
        }
852
    }
853
    Ok(v)
854
}
855
9ff27da5 Andy McCall ↶ 856
pub fn trust(repo: &Repo, cache: &StateCache, hub_key: &KeyId) -> Result<Trust> {
d23464c0 Andy McCall ↶ 857
    let policy = match repo.current_policy()? {
858
        Some((_, sp)) => {
859
            let p = &sp.policy;
860
            let ids = &p.identities;
861
            let require = |r: &oxy_core::policy::Require| match r {
862
                oxy_core::policy::Require::Signed => "signed",
863
                oxy_core::policy::Require::Sealed => "sealed",
864
                oxy_core::policy::Require::Any => "any",
865
            };
866
            Some(PolicyView {
867
                serial: p.serial,
868
                expires_ms: p.expires,
869
                expired: p.expires != 0 && (p.expires as i64) < crate::db::now_ms(),
870
                roots: p.roots.iter().map(|k| key_view(repo, ids, k)).collect(),
871
                root_threshold: p.root_threshold,
872
                rules: p
873
                    .rules
874
                    .iter()
875
                    .map(|r| RuleView {
876
                        pattern: r.pattern.clone(),
877
                        keys: r.keys.iter().map(|k| key_view(repo, ids, k)).collect(),
878
                        threshold: r.threshold,
879
                        require: require(&r.require).into(),
880
                        allow_conflicts: r.allow_conflicts,
881
                    })
882
                    .collect(),
883
                revoked: p.revoked.iter().map(|k| key_view(repo, ids, k)).collect(),
884
                signatures: sp.sigs.len(),
885
                history: repo.policy_history().map(|h| h.len()).unwrap_or(0),
886
            })
887
        }
888
        None => None,
889
    };
890
    // Who has delegated to the hub: every identity in the policy or store
891
    // that lists the hub key.
892
    let mut delegated = Vec::new();
893
    if let Some(p) = repo.current_policy()?.map(|(_, sp)| sp.policy) {
894
        for i in &p.identities {
895
            if i.delegates.contains(hub_key) {
896
                delegated.push(KeyView { key: i.hash().to_hex(), short: i.hash().short(12), name: i.name.clone(), email: i.email.clone() });
897
            }
898
        }
899
    }
9ff27da5 Andy McCall ↶ 900
    Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
d23464c0 Andy McCall ↶ 901
}