OXYGENHUB

Long hashes show their two ends, the whole in a tip addec2bd6423

Opening the overview at a state address printed all of it (21 heads
joined by +) in the heads warning and the bookmark picker, off the
page. A value over 24 characters now shows its first and last eight,
with the whole of it in a wrapped, square-ish tip on hover or focus.

Closes #198
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 15:55 UTC
Signature
✔ signed by Andy McCall
Hash
addec2bd64233dc417bc7fe3def21815b5960fcb4381bd5d35a8fc2db07594f3
Depends on
6ae3fa0af4f6 d23464c0ac60 ffb67a81188c
Depended on by
62cdf34daec1 810896d41be7
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
7 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/web/filters.rs +29 −0
…
53 unchanged lines
54 54
    Ok(hash.chars().take(12).collect())
55 55
}
56 56
57 +
/// A long hash or state address cut to its two ends, `0826ac0c…f8d97241`
58 +
/// (oxygenhub#198): people check a hash by its start and its end. Short
59 +
/// values stay whole.
60 +
pub fn ends(value: &str, _: &dyn askama::Values) -> askama::Result<String> {
61 +
    Ok(cut_ends(value))
62 +
}
63 +
64 +
pub fn cut_ends(value: &str) -> String {
65 +
    let n = value.chars().count();
66 +
    if n <= 24 {
67 +
        return value.to_string();
68 +
    }
69 +
    let start: String = value.chars().take(8).collect();
70 +
    let end: String = value.chars().skip(n - 8).collect();
71 +
    format!("{start}…{end}")
72 +
}
73 +
57 74
/// Markdown to HTML (already sanitised), marked safe for the template.
58 75
pub fn markdown(text: &str, _: &dyn askama::Values, base_file: &str, base_raw: &str) -> askama::Result<askama::filters::Safe<String>> {
59 76
    Ok(askama::filters::Safe(render::markdown::render(text, base_file, base_raw)))
…
14 unchanged lines
74 91
        assert_eq!(crate::web::state_label("02a6c9d4746a"), "02a6c9d4746a", "one head as it is");
75 92
        assert_eq!(crate::web::state_label("web, with no approval"), "web, with no approval");
76 93
        assert_eq!(crate::web::state_label("C++ is fine"), "C++ is fine", "not heads");
94 +
    }
95 +
}
96 +
97 +
#[cfg(test)]
98 +
mod ends_tests {
99 +
    #[test]
100 +
    fn long_values_keep_both_ends() {
101 +
        assert_eq!(super::cut_ends("main"), "main");
102 +
        let h = "0826ac0cca8ca616b79f8fe6f81ff21a41daca9d0c1f05501c146d9299445564";
103 +
        assert_eq!(super::cut_ends(h), "0826ac0c…99445564");
104 +
        let two = format!("{h}+1e470ca931015aa55dbb9514cf34e9e5c40bede11241eedf23c6576d23d2609e");
105 +
        assert_eq!(super::cut_ends(&two), "0826ac0c…23d2609e");
77 106
    }
78 107
}
modified crates/hub/tests/browse.rs +4 −0
…
154 unchanged lines
155 155
    assert!(at_tag.contains("README.md") && at_tag.contains("class=\"ref-pick\"") && at_tag.contains("side cards"), "{at_tag}");
156 156
    assert!(at_tag.contains("href=\"/andy/demo?at=v0.1\" class=\"on\""), "the tag is the one chosen");
157 157
    assert!(page(&h.app, &format!("/andy/demo?at={}", &c1[..10])).await.contains("README.md"));
158 +
    // A whole hash shows its two ends, the rest in a tip (#198).
159 +
    let at_hash = page(&h.app, &format!("/andy/demo?at={c1}")).await;
160 +
    let ends = format!("{}…{}", &c1[..8], &c1[c1.len() - 8..]);
161 +
    assert!(at_hash.contains(&format!("data-full=\"{c1}\"")) && at_hash.contains(&format!(">{ends}</span>")), "{at_hash}");
158 162
    assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND);
159 163
    let (hits_before, _, _) = oxygenhub::render::highlight::cache_stats();
160 164
    let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
…
139 unchanged lines
modified templates/overview.html +2 −2
…
7 unchanged lines
8 8
<div data-backdrop-avoid>
9 9
<div class="side-layout rev">
10 10
  <div>
11 -
    {% if o.heads > 1 %}<div class="strip warn">{{ at }} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %}
11 +
    {% if o.heads > 1 %}<div class="strip warn">{% if at.len() > 24 %}<span class="hash-tip" tabindex="0" data-full="{{ at }}" aria-label="{{ at }}">{{ at|ends }}</span>{% else %}{{ at }}{% endif %} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %}
12 12
    {% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
13 13
    <div class="bar">
14 14
      <details class="ref-pick">
15 -
        <summary class="go small" aria-haspopup="true" title="Choose a bookmark or tag"><span aria-hidden="true">{% if o.tree.target.kind == crate::repos::view::RefKind::Tag %}🏷{% else %}◆{% endif %}</span> {{ at }} <span aria-hidden="true">▾</span></summary>
15 +
        <summary class="go small" aria-haspopup="true" title="Choose a bookmark or tag"><span aria-hidden="true">{% if o.tree.target.kind == crate::repos::view::RefKind::Tag %}🏷{% else %}◆{% endif %}</span> {% if at.len() > 24 %}<span class="hash-tip" tabindex="0" data-full="{{ at }}" aria-label="{{ at }}">{{ at|ends }}</span>{% else %}{{ at }}{% endif %} <span aria-hidden="true">▾</span></summary>
16 16
        <div class="ref-panel" role="menu">
17 17
          <p class="small mute">Bookmarks</p>
18 18
          <ul class="plain">{% for b in o.bookmarks %}<li><a role="menuitem" href="{{ repo.url }}{% if b.name != repo.default_bookmark %}?at={{ b.name|urlencode }}{% endif %}"{% if b.name == at %} class="on"{% endif %}>◆ {{ b.name }}{% if b.name == repo.default_bookmark %} <span class="tag">default</span>{% endif %}</a></li>{% endfor %}</ul>
…
95 unchanged lines
modified templates/style.css +5 −0
…
1244 unchanged lines
1245 1245
.wiki-side-col .clone input { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
1246 1246
.wiki-doc h1[id], .wiki-doc h2[id], .wiki-doc h3[id] { scroll-margin-top: 16px; }
1247 1247
.EasyMDEContainer .editor-toolbar button.mde-btn.mde-text, .EasyMDEContainer .editor-toolbar button.mde-btn.mde-text::first-letter { text-transform: none; }
1248 +
/* A long hash or state address shows its two ends; the whole of it in a
1249 +
   wrapped, square-ish tip on hover or focus (oxygenhub#198). */
1250 +
.hash-tip { position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; cursor: help; border-bottom: 1px dotted currentColor; white-space: nowrap; }
1251 +
.hash-tip::after { content: attr(data-full); position: absolute; z-index: 40; top: calc(100% + 6px); left: 0; width: min(34ch, 80vw); padding: 8px 10px; background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.15); white-space: normal; overflow-wrap: anywhere; word-break: break-all; line-height: 1.45; font-size: 12px; visibility: hidden; opacity: 0; transition: opacity .12s; pointer-events: none; text-transform: none; font-weight: 400; }
1252 +
.hash-tip:hover::after, .hash-tip:focus::after { visibility: visible; opacity: 1; }