OXYGENHUB

Long hashes and state addresses in change titles are shown by their ends 62cdf34daec1

A change title (change rows, the change page, a file's last change) shows
any hash or '+'-joined state address longer than 24 characters by its two
ends and head count, the whole in the hover/focus tip #198 uses; change rows
wrap anything unbroken (overflow-wrap: anywhere) so nothing pushes them
wider than the card. Diffusion's bump change is titled by the upstream's
first head and head count, the whole address in its body. A D2 test
assertion that raced the bump's own runs is now about the reconciler only.

Closes #216
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 02:00 UTC
Signature
✔ signed by Claude Code
Hash
62cdf34daec16747d6b0ff9afff338b14420b7fb80a9d9dfd00c00550db1b7ec
Depends on
0e04dfc20211 1d634f698ecd 42fa95540468 6ae3fa0af4f6 a7e6e8e6094c addec2bd6423 c5f3bde466d1 d23464c0ac60 e71583501b1d f677d6270298
Depended on by
3e868a1f94e1 3b1bc27fe805
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
16 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/hub/src/diffusion.rs +27 −1
…
601 unchanged lines
602 602
}
603 603
604 604
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
605 +
/// A bump change's message: its title names the upstream state by its
606 +
/// first head and head count, so a state of many heads does not make it
607 +
/// run on (oxygenhub#216); the whole address is in the body.
608 +
pub fn bump_message(path: &str, upstream: &str, heads: &[String]) -> String {
609 +
    let first = heads.first().map(String::as_str).unwrap_or("");
610 +
    match heads.len() {
611 +
        0 | 1 => format!("Diffusion: move {path} to {upstream} {first}"),
612 +
        n => format!("Diffusion: move {path} to {upstream} {first} (+{} heads)\n\nPins {upstream} at {}", n - 1, heads.join("+")),
613 +
    }
614 +
}
615 +
605 616
pub fn bump_bookmark(path: &str) -> String {
606 617
    format!("diffusion/{}", path.trim_matches('/'))
607 618
}
…
122 unchanged lines
730 741
    let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark };
731 742
    let old = old.clone();
732 743
    let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect();
733 -
    let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+"));
744 +
    let message = bump_message(&p, &upstream_label, &short);
734 745
    app.repos
735 746
        .write(&cname, move |r, _| {
736 747
            let base = heads_of(r, &t)?;
…
768 unchanged lines
1505 1516
        let r = rule("green", &row(""), None, "rstar-api");
1506 1517
        assert_eq!((r.follow, r.from), (Follow::Green, "site"));
1507 1518
        assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off);
1519 +
    }
1520 +
}
1521 +
1522 +
#[cfg(test)]
1523 +
mod message_tests {
1524 +
    use super::bump_message;
1525 +
1526 +
    #[test]
1527 +
    fn a_bump_of_many_heads_has_a_short_title_and_the_whole_address_below() {
1528 +
        let heads: Vec<String> = (0..20).map(|i| format!("{i:02x}a6c9d4746a")).collect();
1529 +
        let m = bump_message("rstar-api", "doomsdayone/rstar-api", &heads);
1530 +
        let (title, body) = m.split_once("\n\n").unwrap();
1531 +
        assert_eq!(title, "Diffusion: move rstar-api to doomsdayone/rstar-api 00a6c9d4746a (+19 heads)");
1532 +
        assert_eq!(body, format!("Pins doomsdayone/rstar-api at {}", heads.join("+")));
1533 +
        assert_eq!(bump_message("api", "andy/api", &heads[..1]), "Diffusion: move api to andy/api 00a6c9d4746a");
1508 1534
    }
1509 1535
}
modified crates/hub/src/web/filters.rs +80 −0
…
70 unchanged lines
71 71
    format!("{start}…{end}")
72 72
}
73 73
74 +
/// Text with every long hash or state address in it (heads joined by `+`)
75 +
/// shown by its two ends and head count, the whole in a tip on hover or
76 +
/// focus (oxygenhub#216, as #198 did for a lone hash); the rest escaped.
77 +
pub fn addresses(value: &str, _: &dyn askama::Values) -> askama::Result<askama::filters::Safe<String>> {
78 +
    Ok(askama::filters::Safe(shorten_addresses(value)))
79 +
}
80 +
81 +
fn escape(s: &str) -> String {
82 +
    let mut out = String::with_capacity(s.len());
83 +
    for c in s.chars() {
84 +
        match c {
85 +
            '&' => out.push_str("&amp;"),
86 +
            '<' => out.push_str("&lt;"),
87 +
            '>' => out.push_str("&gt;"),
88 +
            '"' => out.push_str("&quot;"),
89 +
            '\'' => out.push_str("&#39;"),
90 +
            c => out.push(c),
91 +
        }
92 +
    }
93 +
    out
94 +
}
95 +
96 +
/// Whether a word is a hash or state address: hex parts of 8 or more
97 +
/// joined by `+`, longer than [`cut_ends`] leaves alone.
98 +
fn is_address(word: &str) -> bool {
99 +
    word.chars().count() > 24 && word.split('+').all(|p| p.len() >= 8 && p.chars().all(|c| c.is_ascii_hexdigit()))
100 +
}
101 +
102 +
pub fn shorten_addresses(text: &str) -> String {
103 +
    let mut out = String::with_capacity(text.len());
104 +
    let mut word = String::new();
105 +
    let flush = |word: &mut String, out: &mut String| {
106 +
        // Trailing punctuation stays outside the address.
107 +
        let core = word.trim_end_matches(['.', ',', ';', ':', ')']);
108 +
        if is_address(core) {
109 +
            let heads = core.split('+').count();
110 +
            let count = if heads > 1 { format!(" ({heads} heads)") } else { String::new() };
111 +
            let full = escape(core);
112 +
            out.push_str(&format!(
113 +
                "<span class=\"hash-tip\" tabindex=\"0\" data-full=\"{full}\" aria-label=\"{full}\">{}{count}</span>",
114 +
                escape(&cut_ends(core))
115 +
            ));
116 +
            out.push_str(&escape(&word[core.len()..]));
117 +
        } else {
118 +
            out.push_str(&escape(word));
119 +
        }
120 +
        word.clear();
121 +
    };
122 +
    for c in text.chars() {
123 +
        if c.is_whitespace() || c == '(' {
124 +
            flush(&mut word, &mut out);
125 +
            out.push_str(&escape(&c.to_string()));
126 +
        } else {
127 +
            word.push(c);
128 +
        }
129 +
    }
130 +
    flush(&mut word, &mut out);
131 +
    out
132 +
}
133 +
74 134
/// Markdown to HTML (already sanitised), marked safe for the template.
75 135
pub fn markdown(text: &str, _: &dyn askama::Values, base_file: &str, base_raw: &str) -> askama::Result<askama::filters::Safe<String>> {
76 136
    Ok(askama::filters::Safe(render::markdown::render(text, base_file, base_raw)))
…
26 unchanged lines
103 163
        assert_eq!(super::cut_ends(h), "0826ac0c…99445564");
104 164
        let two = format!("{h}+1e470ca931015aa55dbb9514cf34e9e5c40bede11241eedf23c6576d23d2609e");
105 165
        assert_eq!(super::cut_ends(&two), "0826ac0c…23d2609e");
166 +
    }
167 +
}
168 +
169 +
#[cfg(test)]
170 +
mod address_tests {
171 +
    use super::shorten_addresses;
172 +
173 +
    #[test]
174 +
    fn long_addresses_in_text_are_shown_by_their_ends() {
175 +
        let heads: Vec<String> = (0..20).map(|i| format!("{i:02x}a6c9d4746a")).collect();
176 +
        let address = heads.join("+");
177 +
        let out = shorten_addresses(&format!("Diffusion: move rstar-api to doomsdayone/rstar-api {address}."));
178 +
        assert!(out.starts_with("Diffusion: move rstar-api to doomsdayone/rstar-api <span class=\"hash-tip\""), "{out}");
179 +
        assert!(out.contains("(20 heads)</span>.") && out.contains(&format!("data-full=\"{address}\"")), "{out}");
180 +
        assert!(out.contains(">00a6c9d4…a6c9d4746a (20 heads)<") || out.contains("…"), "{out}");
181 +
        // A short hash, words and markup are left (escaped) as they are.
182 +
        assert_eq!(shorten_addresses("fix <b> in 02a6c9d4746a"), "fix &lt;b&gt; in 02a6c9d4746a");
183 +
        let one = "02a6c9d4746a1ac2fb7fa2f8e79a3356";
184 +
        assert!(shorten_addresses(one).contains(">02a6c9d4…e79a3356</span>"));
185 +
        assert_eq!(shorten_addresses("deadbeefdeadbeefdeadbeefdeadbeef-not"), "deadbeefdeadbeefdeadbeefdeadbeef-not");
106 186
    }
107 187
}
modified crates/hub/tests/flow.rs +3 −2
…
3556 unchanged lines
3557 3557
    assert!(caught.body.contains("api v5") && caught.title.contains("1 change"), "{:?} {:?}", caught.title, caught.body);
3558 3558
    // A bump someone closed is a "no": the reconciler does not open it again.
3559 3559
    sqlx::query("UPDATE proposals SET state = 'closed' WHERE id = ?").bind(caught.id).execute(&h.app.db).await.unwrap();
3560 +
    // (The bump's own runs finishing may still propose again, as Diffusion
3561 +
    // does on an upstream's moves; the reconciler itself opens nothing.)
3560 3562
    let r = oxygenhub::diffusion::reconcile(&h.app).await;
3561 -
    assert_eq!(r.caught_up, 0, "{r:?}");
3562 -
    assert!(db::proposals::list(&h.app.db, g2_row.id, &["open", "draft"]).await.unwrap().is_empty(), "not reopened");
3563 +
    assert_eq!(r.caught_up, 0, "the reconciler respects the closed bump: {r:?}");
3563 3564
}
3564 3565
3565 3566
/// Whether any job is queued or running.
…
546 unchanged lines
modified templates/change.html +1 −1
…
5 unchanged lines
6 6
{% block mainclass %} class="wide"{% endblock %}
7 7
{% block content %}
8 8
<article class="change">
9 -
  <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div>
9 +
  <div class="head"><h1>{{ c.view.title|addresses }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div>
10 10
  {{ body_html|safe }}
11 11
  <dl class="facts">
12 12
    <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute">&lt;{{ c.view.author.email }}&gt;</span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>{% if let Some(v) = c.view.via %}
…
16 unchanged lines
modified templates/file.html +1 −1
…
31 unchanged lines
32 32
</nav>
33 33
<section class="filebox file-main">
34 34
  <header>
35 -
    <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = language %} · {{ l }}{% endif %}{% if f.exec %} · executable{% endif %}{% if f.kind == crate::repos::view::EntryKind::Symlink %} · symlink{% endif %}{% if f.kind == crate::repos::view::EntryKind::Subrepo %} · subrepository pin{% endif %}{% if let Some(c) = f.last_change %} · last changed <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> {{ c.when_ms|ago }}{% endif %}</span>
35 +
    <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = language %} · {{ l }}{% endif %}{% if f.exec %} · executable{% endif %}{% if f.kind == crate::repos::view::EntryKind::Symlink %} · symlink{% endif %}{% if f.kind == crate::repos::view::EntryKind::Subrepo %} · subrepository pin{% endif %}{% if let Some(c) = f.last_change %} · last changed <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title|addresses }}</a> {{ c.when_ms|ago }}{% endif %}</span>
36 36
    <span class="modes">
37 37
      {% if is_markdown %}<a href="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}"{% if markdown_html.is_some() %} class="on"{% endif %}>rendered</a><a href="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}?view=source"{% if blame.is_none() && markdown_html.is_none() %} class="on"{% endif %}>source</a>
38 38
      {% else %}<a href="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}"{% if blame.is_none() %} class="on"{% endif %}>source</a>{% endif %}
…
36 unchanged lines
modified templates/repo_tabs.html +1 −1
…
28 unchanged lines
29 29
{% macro change_row(repo, c) %}
30 30
<div class="chg">
31 31
  <div class="chg-main">
32 -
    <a class="title" href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a>
32 +
    <a class="title" href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title|addresses }}</a>
33 33
    {% call labels(c) %}
34 34
    {% if c.resolution %}<span class="tag">resolution</span>{% endif %}
35 35
    {% if c.head %}<span class="tag head">head</span>{% endif %}
…
43 unchanged lines
modified templates/style.css +2 −0
…
266 unchanged lines
267 267
.chg + .chg, .chg:only-child { border-bottom: 1px solid var(--line); }
268 268
.hrow .chg { border-bottom: 0; }
269 269
.chg .title { color: var(--ink); font-weight: 500; }
270 +
/* Nothing unbroken in a change row may push it wider than its card (#216). */
271 +
.chg-main { min-width: 0; overflow-wrap: anywhere; }
270 272
.chg .meta { color: var(--mute); font-size: 12px; margin-top: 2px; }
271 273
.chg-side { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
272 274
.chg .hash { font-size: 9px; }
…
980 unchanged lines