copy: a shortened id keeps its prefix, the way git shortens a hash
Build Debug APK / build (push) Failing after 3m9s

truncate_id/short_id kept the first `keep` characters and the last four
(3fa9c1d2…a1b2), cutting ids from the middle like readable text. Every id
here is random (UUIDv4/v5, node keys, BLAKE3 hashes), so the end is no more
distinguishing than the start, and the prefix is the conventional handle:
3fa9c1d2…. A scheme label such as `blake3:` is kept whole and not counted, so
a blob hash still shows eight characters of the hash itself. Every hash chip
on desktop and Android (via the short_id/truncate_id exports) and the browser
extension's truncateId change with it; ADR 0026 records the amendment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
This commit is contained in:
Greg Shuflin
2026-09-28 20:25:08 -07:00
co-authored by Claude Opus 5.5
parent 8c43229925
commit c42454854d
3 changed files with 51 additions and 36 deletions
+12 -10
View File
@@ -1,18 +1,16 @@
/**
* Truncation that cuts from the middle.
* Truncation: readable text cuts from the middle, ids keep their prefix.
*
* An end cut drops whatever tells two long strings apart when they share a
* beginning, so every truncation in this project keeps both ends instead. The
* rule and its reasoning live in the Rust lib (`copy::elide_middle`,
* beginning, so a truncation of readable text keeps both ends. An id is random,
* so it is shortened to its prefix, the way git shortens a hash. The rules and
* their reasoning live in the Rust lib (`copy::elide_middle`,
* `copy::truncate_id`); the extension has no way to call that, so this is the
* same rule written again, and has to move when that one does.
*/
const ELLIPSIS = "…";
/** How many trailing characters `truncateId` keeps after the ellipsis. */
const ID_TAIL = 4;
/** How many characters an ellipsis has to save before `truncateId` cuts at all. */
const ELLIPSIS_MIN_SAVINGS = 4;
@@ -49,16 +47,20 @@ export function elideMiddle(text, maxChars) {
}
/**
* A hex id or uuid cut to its first `keep` characters, an ellipsis, and its
* last few. Passes through when the cut would save almost nothing.
* A hex id or uuid cut to its first `keep` characters and an ellipsis. A
* `scheme:` label such as `blake3:` is kept whole and not counted. Passes
* through when the cut would save almost nothing.
*
* @param {string} id
* @param {number} keep
* @returns {string}
*/
export function truncateId(id, keep) {
if (Array.from(id).length <= keep + ID_TAIL + 1 + ELLIPSIS_MIN_SAVINGS) return id;
return elideBetween(id, keep, ID_TAIL);
const colon = id.indexOf(":");
const scheme = colon > 0 && /^[A-Za-z0-9]+$/.test(id.slice(0, colon)) ? id.slice(0, colon + 1) : "";
const body = Array.from(id.slice(scheme.length));
if (body.length <= keep + 1 + ELLIPSIS_MIN_SAVINGS) return id;
return `${scheme}${body.slice(0, keep).join("")}${ELLIPSIS}`;
}
/**
@@ -34,12 +34,15 @@ per modality, and the framework-independent pieces shared in Rust:
| Android | `ui/IdRef.kt` (`IdRef`) | tap to toggle full/short |
| TUI | *(none yet)* | undecided — see Consequences |
Truncation itself is `synchronicity::copy::short_id`: the first 8 characters,
`…`, and the last 4, passing an id through untouched when the cut would save
four characters or fewer. The cut is in the middle, like every truncation in
the project (see `copy::elide_middle`): two ids minted together can share a
prefix, and a prefixed one (`blake3:…`) spends most of an 8-character head on
the part every id has in common. It lives in
Truncation itself is `synchronicity::copy::short_id`: the first 8 characters
and `…`, passing an id through untouched when the cut would save four
characters or fewer. A `scheme:` label (`blake3:`) is kept whole and not
counted. *Amended 2026-09-28:* this used to keep the last 4 characters as well,
cutting in the middle like readable text. Ids now keep only their prefix, the
way git shortens a hash: every id here is random (UUIDv4/v5, node keys, BLAKE3
hashes), so its end is no more distinguishing than its start, and the prefix
is the conventional handle. Readable text still cuts from the middle (see
`copy::elide_middle`, and CLAUDE.md). It lives in
`lib` rather than in either frontend so the FFI can precompute both forms
(`ffi.rs`'s `JobItem`) and Android renders the same string desktop does
without a second implementation of the rule.
+30 -20
View File
@@ -50,9 +50,6 @@ pub mod human_display {
/// What stands in for the characters a truncation dropped.
pub const ELLIPSIS: char = '…';
/// How many trailing characters [`truncate_id`] keeps after the ellipsis.
const ID_TAIL: usize = 4;
/// How many characters an ellipsis has to save before [`truncate_id`] bothers
/// cutting at all.
const ELLIPSIS_MIN_SAVINGS: usize = 4;
@@ -60,11 +57,11 @@ const ELLIPSIS_MIN_SAVINGS: usize = 4;
/// `text` with its middle replaced by an ellipsis so that it is at most
/// `max_chars` characters long, ellipsis included.
///
/// Every truncation in this project cuts from the middle rather than the end:
/// what tells two long strings apart is as often at the end as at the start
/// (`Quarterly report - draft` / `Quarterly report - final`, an episode
/// number, a file extension, the last digits of a hash), and an end cut throws
/// exactly that away. Layout-driven truncation follows the same rule through
/// Every truncation of readable text in this project cuts from the middle
/// rather than the end: what tells two long strings apart is as often at the
/// end as at the start (`Quarterly report - draft` / `Quarterly report -
/// final`, an episode number, a file extension), and an end cut throws exactly
/// that away. Ids are the exception — see [`truncate_id`]. Layout-driven truncation follows the same rule through
/// each toolkit's own middle ellipsis; this is for a cut made by character
/// count. The head gets the odd character, and whitespace next to the cut is
/// dropped rather than shown.
@@ -87,16 +84,28 @@ fn elide_between(text: &str, head: usize, tail: usize) -> String {
format!("{}{ELLIPSIS}{}", text[..head_end].trim_end(), text[tail_start..].trim_start())
}
/// Truncate a hex id or UUID to its first `keep` characters, an ellipsis, and
/// its last few, for compact list rows — the full value should stay available
/// via a tap/hover/long-press/click elsewhere. Passes through unchanged when
/// the cut would save four characters or fewer.
/// Shorten a hex id or UUID to its first `keep` characters and an ellipsis,
/// the way git shortens a commit hash. An id is random, so its end is no more
/// distinguishing than its start, and the prefix is the conventional handle —
/// the part people read aloud, compare and type. A `scheme:` label such as
/// `blake3:` is kept whole and not counted, since every id of that kind shares
/// it. The full value should stay available via a tap/hover/long-press/click
/// elsewhere. Passes through unchanged when the cut would save four characters
/// or fewer.
pub fn truncate_id(id: &str, keep: usize) -> String {
if id.chars().count() <= keep + ID_TAIL + 1 + ELLIPSIS_MIN_SAVINGS {
id.to_string()
} else {
elide_between(id, keep, ID_TAIL)
let (scheme, body) = match id.split_once(':') {
Some((scheme, _))
if !scheme.is_empty() && scheme.chars().all(|c| c.is_ascii_alphanumeric()) =>
{
id.split_at(scheme.len() + 1)
}
_ => ("", id),
};
if body.chars().count() <= keep + 1 + ELLIPSIS_MIN_SAVINGS {
return id.to_string();
}
let head: String = body.chars().take(keep).collect();
format!("{scheme}{head}{ELLIPSIS}")
}
/// [`truncate_id`] at the everyday 8-character cut.
@@ -607,11 +616,12 @@ mod tests {
type Resolve = fn(Option<&str>) -> (String, bool);
#[rstest]
#[case("blake3:abcdef0123456789", "blake3:a…6789")]
#[case("1234567890123456789012", "12345678…9012")]
#[case("1234567890123456789012", "12345678…")]
#[case("blake3:abcdef0123456789", "blake3:abcdef01…")]
#[case("short", "short")]
#[case("seventeen-chars-1", "seventeen-chars-1")]
fn short_id_truncates_long_ids_and_passes_through_short_ones(
#[case("1234567890123", "1234567890123")]
#[case("12345678901234", "12345678…")]
fn short_id_keeps_the_prefix_and_passes_through_short_ones(
#[case] input: &str,
#[case] expected: &str,
) {