@@ -69,6 +69,23 @@ impl Palette {
6969 let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
7070 self.wrap(code, s)
7171 }
72+ /// colour with a raw ANSI code (used for meaning-coloured glyphs)
73+ pub fn code(&self, code: &str, s: &str) -> String {
74+ self.wrap(code, s)
75+ }
76+ /// Apply a background band across a whole line. The line already contains
77+ /// colour codes whose `\x1b[0m` resets would clear the background mid-line,
78+ /// so the background is re-applied after every reset. No-op when colour is
79+ /// off, so the focus stays marked only by the `▶` gutter (§Colour).
80+ pub fn bg_line(&self, bg: &str, s: &str) -> String {
81+ if !self.on {
82+ return s.to_string();
83+ }
84+ let onset = format!("\x1b[{}m", bg);
85+ let reapply = format!("\x1b[0m{}", onset);
86+ let body = s.replace("\x1b[0m", &reapply);
87+ format!("{}{}\x1b[0m", onset, body)
88+ }
7289}
7390
7491pub fn color_enabled(mode: &str) -> bool {
@@ -421,7 +438,8 @@ fn node_glyph(
421438}
422439
423440fn has_conflict(c: &Value) -> Result<bool, Crash> {
424− for e in c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?.iter() {
441+ let files = c.field("files")?;
442+ for e in files.as_list()?.iter() {
425443 if let Value::Blob(b) = e.field("content")? {
426444 if b.is_unresolved() {
427445 return Ok(true);
@@ -451,7 +469,7 @@ fn display_block(
451469 return Ok(());
452470 }
453471 Value::Blob(b) => {
454− let content = String::from_utf8_lossy(&b.bytes()).to_string();
472+ let content = String::from_utf8_lossy(&b.bytes()?).to_string();
455473 out.push_str(&indent_multiline(&content, indent));
456474 return Ok(());
457475 }
@@ -885,42 +903,38 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
885903}
886904
887905pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
888− let detail = match opts.field("detail")? {
889− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(1),
890− _ => return Err(Crash::new("treeWith: detail must be an Int")),
891− };
892− let margin = match opts.field("margin")? {
893− Value::Bool(b) => b,
894− _ => return Err(Crash::new("treeWith: margin must be a Bool")),
895− };
896− let elide = match opts.field("elide")? {
897− Value::Bool(b) => b,
898− _ => return Err(Crash::new("treeWith: elide must be a Bool")),
906+ let defaults = default_tree_options(interp)?;
907+ // a missing option falls back to its default, so an older config that
908+ // predates a newer option keeps working; a present option is type-checked
909+ let bool_opt = |name: &str, default: bool| -> Result<bool, Crash> {
910+ match opts.field(name) {
911+ Ok(Value::Bool(b)) => Ok(b),
912+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be a Bool", name))),
913+ Err(_) => Ok(default),
914+ }
899915 };
900− let icons = match opts.field("icons")? {
901− Value::Bool(b) => b,
902− _ => return Err(Crash::new("treeWith: icons must be a Bool")),
916+ let int_opt = |name: &str, default: i64| -> Result<i64, Crash> {
917+ match opts.field(name) {
918+ Ok(Value::Int(n)) => Ok(n.to_string().parse::<i64>().unwrap_or(default)),
919+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be an Int", name))),
920+ Err(_) => Ok(default),
921+ }
903922 };
904− let color = opts.field("color")?.as_text()?.to_string();
905− let lanes = match opts.field("lanes")? {
906− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(0),
907− _ => return Err(Crash::new("treeWith: lanes must be an Int")),
923+ let detail = int_opt("detail", defaults.detail)?;
924+ let margin = bool_opt("margin", defaults.margin)?;
925+ let elide = bool_opt("elide", defaults.elide)?;
926+ let icons = bool_opt("icons", defaults.icons)?;
927+ let color = match opts.field("color") {
928+ Ok(v) => v.as_text()?.to_string(),
929+ Err(_) => defaults.color.clone(),
908930 };
931+ let lanes = int_opt("lanes", defaults.lanes)?;
909932 if lanes < 1 {
910933 return Err(Crash::new("treeWith: lanes must be at least 1"));
911934 }
912− let author = match opts.field("author")? {
913− Value::Bool(b) => b,
914− _ => return Err(Crash::new("treeWith: author must be a Bool")),
915− };
916− let date = match opts.field("date")? {
917− Value::Bool(b) => b,
918− _ => return Err(Crash::new("treeWith: date must be a Bool")),
919− };
920− let files = match opts.field("files")? {
921− Value::Bool(b) => b,
922− _ => return Err(Crash::new("treeWith: files must be a Bool")),
923− };
935+ let author = bool_opt("author", defaults.author)?;
936+ let date = bool_opt("date", defaults.date)?;
937+ let files = bool_opt("files", defaults.files)?;
924938 let pal = Palette {
925939 on: color_enabled(&color),
926940 };
@@ -981,7 +995,6 @@ impl CommitInfo {
981995fn build_info(
982996 interp: &mut Interp,
983997 opts: &TreeOptions,
984− loc: &Value,
985998 commit: &Value,
986999 children: &[Value],
9871000 immutable: &BTreeSet<String>,
@@ -990,6 +1003,7 @@ fn build_info(
9901003 parent_files: Option<&Value>,
9911004 with_focus: bool,
9921005 parent_is_ancestor: bool,
1006+ parent_is_focus: bool,
9931007) -> Result<CommitInfo, Crash> {
9941008 let id = match commit.field("id")? {
9951009 Value::Id(i) => i.to_string(),
@@ -1005,31 +1019,50 @@ fn build_info(
10051019 let conflict = has_conflict(commit)?;
10061020 let files = commit.field("files")?;
10071021 let nfiles = files.as_list()?.len();
1022+ // `empty` (no change against the parent) is needed at every detail level;
1023+ // compute it by id-based snapshot equality, which never forces blob bytes.
1024+ // `size` (a line count) and `detail_marks` are only rendered for detail ≥
1025+ // 2 and for the focus and its neighbours, so skip the per-commit line
1026+ // counting elsewhere — it dominates render time on large histories.
1027+ let is_focus_pre = with_focus && id == focus_id;
1028+ let child_is_focus = with_focus
1029+ && children.iter().any(|c| {
1030+ matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
1031+ });
1032+ let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
10081033 let (empty, size, detail_marks) = match parent_files {
10091034 Some(pf) => {
1010− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1011− let marks = touched_paths(&change)?;
1012− let empty = marks.is_empty();
1013− // size: lines added+removed against the parent
1014− let from_map = snapshot_map_of(pf)?;
1015− let to_map = snapshot_map_of(&files)?;
1016− let mut lines = 0usize;
1017− for (p, _, _) in &marks {
1018− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1019− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1020− match m.get(&key) {
1021− Some(Value::Blob(b)) => {
1022− let n = String::from_utf8_lossy(&b.bytes()).lines().count();
1023− n.max(1)
1035+ // empty ⟺ the snapshot equals the parent's (id-based, no forcing)
1036+ let empty = crate::value::value_eq(&files, pf)?;
1037+ if want_diff {
1038+ let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1039+ let marks = touched_paths(&change)?;
1040+ // size: lines added+removed against the parent
1041+ let from_map = snapshot_map_of(pf)?;
1042+ let to_map = snapshot_map_of(&files)?;
1043+ let mut lines = 0usize;
1044+ for (p, _, _) in &marks {
1045+ let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1046+ let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1047+ match m.get(&key) {
1048+ Some(Value::Blob(b)) => {
1049+ let n = b
1050+ .bytes()
1051+ .map(|v| String::from_utf8_lossy(&v).lines().count())
1052+ .unwrap_or(1);
1053+ n.max(1)
1054+ }
1055+ _ => 1,
10241056 }
1025− _ => 1,
1026− }
1027− };
1028− lines += count(&from_map) + count(&to_map);
1057+ };
1058+ lines += count(&from_map) + count(&to_map);
1059+ }
1060+ let marks2: Vec<(String, char)> =
1061+ marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1062+ (marks.is_empty(), Some(lines), marks2)
1063+ } else {
1064+ (empty, None, Vec::new())
10291065 }
1030− let marks2: Vec<(String, char)> =
1031− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1032− (empty, Some(lines), marks2)
10331066 }
10341067 None => {
10351068 let n = files.as_list()?.len();
@@ -1041,15 +1074,9 @@ fn build_info(
10411074 let is_ancestor_of_focus = anc.contains(&id);
10421075 let mut infos = Vec::new();
10431076 for c in children.iter() {
1044− let child_loc = crate::repo::by_id(loc, &match c.field("root")?.field("id")? {
1045− Value::Id(i) => i.to_string(),
1046− _ => continue,
1047− })?
1048− .unwrap();
10491077 infos.push(build_info(
10501078 interp,
10511079 opts,
1052− &child_loc,
10531080 &c.field("root")?,
10541081 c.field("children")?.as_list()?,
10551082 immutable,
@@ -1058,6 +1085,7 @@ fn build_info(
10581085 Some(&files),
10591086 with_focus,
10601087 is_ancestor_of_focus,
1088+ is_focus_pre,
10611089 )?);
10621090 }
10631091 let immutable_flag = immutable.contains(&id);
@@ -1092,20 +1120,19 @@ fn tree_render(
10921120 Value::Id(i) => i.to_string(),
10931121 _ => String::new(),
10941122 };
1095− // ancestors of focus
1123+ // ancestors of focus: the focus id plus every parent recorded in the
1124+ // zipper's context frames — read directly rather than refocusing each
1125+ // frame (an O(n) by_id per level would make this O(n²))
10961126 let mut anc: BTreeSet<String> = BTreeSet::new();
1097− let mut cur = repo.clone();
1098− loop {
1099− let id = match cur.field("root")?.field("id")? {
1100− Value::Id(i) => i.to_string(),
1101− _ => break,
1102− };
1103− anc.insert(id);
1104− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1105− if ctx.is_empty() {
1106− break;
1127+ if let Ok(Value::Id(i)) = repo.field("root").and_then(|r| r.field("id")) {
1128+ anc.insert(i.to_string());
1129+ }
1130+ if let Ok(ctx) = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec())) {
1131+ for frame in &ctx {
1132+ if let Ok(pid) = id_of_frame_parent(frame) {
1133+ anc.insert(pid);
1134+ }
11071135 }
1108− cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
11091136 }
11101137 // the whole history, from the top
11111138 let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
@@ -1114,7 +1141,6 @@ fn tree_render(
11141141 let root_info = build_info(
11151142 interp,
11161143 opts,
1117− &top,
11181144 &root_commit,
11191145 top_children.as_list()?,
11201146 &immutable,
@@ -1123,6 +1149,7 @@ fn tree_render(
11231149 None,
11241150 with_focus,
11251151 false,
1152+ false,
11261153 )?;
11271154 // trunk T (§Trunk)
11281155 let trunk = compute_trunk(interp, repo)?;
@@ -1182,17 +1209,16 @@ fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
11821209}
11831210
11841211fn top_id(repo: &Value) -> Result<String, Crash> {
1185− let mut cur = repo.clone();
1186− loop {
1187− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1188− if ctx.is_empty() {
1189− return match cur.field("root")?.field("id")? {
1190− Value::Id(i) => Ok(i.to_string()),
1191− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1192− };
1193− }
1194− let pid = id_of_frame_parent(&ctx[0])?;
1195− cur = crate::repo::by_id(&cur, &pid)?.unwrap();
1212+ // the topmost ancestor's id: the focus's own id when the context is empty,
1213+ // otherwise the parent recorded in the *last* (outermost) zipper frame —
1214+ // read directly, since refocusing each frame with by_id is O(n²) overall
1215+ let ctx = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1216+ match ctx.last() {
1217+ None => match repo.field("root")?.field("id")? {
1218+ Value::Id(i) => Ok(i.to_string()),
1219+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1220+ },
1221+ Some(frame) => id_of_frame_parent(frame),
11961222 }
11971223}
11981224
@@ -1624,10 +1650,13 @@ fn build_row_text(
16241650 } else {
16251651 String::new()
16261652 };
1627− // message
1653+ // message, coloured by state (§Colour): focus bold, conflict red, empty
1654+ // dim-italic; the collapsed marker is dim
16281655 let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
16291656 let mut msg = match c {
16301657 Some(info) if info.empty => pal.dim_italic(msg_first),
1658+ Some(info) if info.is_focus => pal.bold(msg_first),
1659+ Some(info) if info.conflict => pal.red(msg_first),
16311660 _ => msg_first.to_string(),
16321661 };
16331662 if let Display::Collapsed { hidden, .. } = n {
@@ -1635,7 +1664,7 @@ fn build_row_text(
16351664 if !msg.is_empty() {
16361665 msg.push_str(" ");
16371666 }
1638− msg.push_str(&format!("⋯ {}", hidden));
1667+ msg.push_str(&pal.dim(&format!("⋯ {}", hidden)));
16391668 }
16401669 }
16411670 let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
@@ -1755,7 +1784,12 @@ fn draw_rows(
17551784 let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
17561785 let mut line = String::new();
17571786 line.push_str(&t.gutter);
1758− line.push_str(&color_rails(&chars, &lane0, pal));
1787+ // the node glyph (on a commit row) is meaning-coloured; rails lane-coloured
1788+ let glyph = match (n, pl.lane) {
1789+ (Display::Commit { info, .. }, Some(l)) => Some((2 * l, glyph_ansi(info))),
1790+ _ => None,
1791+ };
1792+ line.push_str(&color_rails(&chars, &lane0, pal, glyph));
17591793 line.push(' ');
17601794 line.push_str(&t.id);
17611795 if !t.bar.is_empty() {
@@ -1791,7 +1825,26 @@ fn draw_rows(
17911825 line.push_str(&meta.join(" "));
17921826 }
17931827 let _ = pl;
1794− lines.push(line);
1828+ // the whole focus row is highlighted with a background band across the
1829+ // full terminal width (§Colour; colour only — the ▶ gutter still marks
1830+ // the focus when colour is off)
1831+ let is_focus_row = n.commit().map(|c| c.is_focus).unwrap_or(false);
1832+ if is_focus_row && pal.on {
1833+ let target_w = if crate::show::stdout_is_tty() {
1834+ crate::show::terminal_width()
1835+ } else {
1836+ None
1837+ };
1838+ let padded = match target_w {
1839+ Some(w) if width(&line) < w => {
1840+ format!("{}{}", line, " ".repeat(w - width(&line)))
1841+ }
1842+ _ => line.clone(),
1843+ };
1844+ lines.push(pal.bg_line("48;5;236", &padded));
1845+ } else {
1846+ lines.push(line);
1847+ }
17951848 // detail line (detail = 2, focus only)
17961849 if opts.detail >= 2
17971850 && with_focus
@@ -1801,7 +1854,7 @@ fn draw_rows(
18011854 let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
18021855 let mut dline = String::new();
18031856 dline.push_str(" ");
1804− dline.push_str(&color_rails(&dchars, &dlane0, pal));
1857+ dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
18051858 dline.push(' ');
18061859 dline.push_str(&" ".repeat(id_w + 2));
18071860 let marks: Vec<String> = c
@@ -2120,12 +2173,28 @@ fn detail_rail_row(
21202173 (chars, lane0)
21212174}
21222175
2123−fn color_rails(chars: &[char], lane0: &[bool], pal: &Palette) -> String {
2176+/// Colour the rails. Rail connectors are lane-coloured (lane 0 = immutable
2177+/// blue, others dim); the node glyph at `glyph` (its lane position and ANSI
2178+/// code) is meaning-coloured.
2179+fn color_rails(
2180+ chars: &[char],
2181+ lane0: &[bool],
2182+ pal: &Palette,
2183+ glyph: Option<(usize, &str)>,
2184+) -> String {
21242185 let mut s = String::new();
21252186 for (i, c) in chars.iter().enumerate() {
21262187 let t = c.to_string();
21272188 if *c == ' ' {
21282189 s.push(*c);
2190+ } else if let Some((pos, code)) = glyph {
2191+ if i == pos {
2192+ s.push_str(&pal.code(code, &t));
2193+ } else if lane0[i] {
2194+ s.push_str(&pal.blue(&t));
2195+ } else {
2196+ s.push_str(&pal.dim(&t));
2197+ }
21292198 } else if lane0[i] {
21302199 s.push_str(&pal.blue(&t));
21312200 } else {
@@ -2282,6 +2351,26 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
22822351 }
22832352}
22842353
2354+/// The ANSI colour code for a node glyph, by meaning (§Colour: glyphs carry
2355+/// meaning; colour only adds emphasis). Mirrors the precedence of `glyph_for`.
2356+fn glyph_ansi(info: &CommitInfo) -> &'static str {
2357+ if info.id == ROOT_ID {
2358+ "1" // root: bold
2359+ } else if info.conflict {
2360+ "31" // conflict: red
2361+ } else if info.empty {
2362+ "2" // empty: dim
2363+ } else if info.immutable {
2364+ "34" // immutable: blue
2365+ } else if info.is_focus {
2366+ "1;36" // focus: bold cyan
2367+ } else if info.is_ancestor_of_focus {
2368+ "32" // ancestor of focus: green
2369+ } else {
2370+ "37" // other commit: light grey
2371+ }
2372+}
2373+
22852374fn size_bar(size: Option<usize>, pal: &Palette) -> String {
22862375 match size {
22872376 None | Some(0) => String::new(),