@@ -69,6 +69,23 @@ impl Palette {
69 let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
69 let code = ["31", "32", "33", "34", "35", "36"][hue as usize];
70 self.wrap(code, s)
70 self.wrap(code, s)
72+ /// colour with a raw ANSI code (used for meaning-coloured glyphs)
73+ pub fn code(&self, code: &str, s: &str) -> String {
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 {
82+ return s.to_string();
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)
74pub fn color_enabled(mode: &str) -> bool {
91pub fn color_enabled(mode: &str) -> bool {
@@ -421,7 +438,8 @@ fn node_glyph(
423fn has_conflict(c: &Value) -> Result<bool, Crash> {
440fn has_conflict(c: &Value) -> Result<bool, Crash> {
441+ let files = c.field("files")?;
424− for e in c.field("files").and_then(|v| v.as_list().map(|x| x.to_vec()))?.iter() {
442+ for e in files.as_list()?.iter() {
425 if let Value::Blob(b) = e.field("content")? {
443 if let Value::Blob(b) = e.field("content")? {
426 if b.is_unresolved() {
444 if b.is_unresolved() {
427 return Ok(true);
445 return Ok(true);
@@ -451,7 +469,7 @@ fn display_block(
451 return Ok(());
469 return Ok(());
453 Value::Blob(b) => {
471 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();
455 out.push_str(&indent_multiline(&content, indent));
473 out.push_str(&indent_multiline(&content, indent));
456 return Ok(());
474 return Ok(());
@@ -885,42 +903,38 @@ fn default_tree_options(_interp: &Interp) -> Result<TreeOptions, Crash> {
887pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
905pub fn tree_with(interp: &mut Interp, opts: &Value, repo: &Value) -> Result<Value, Crash> {
888− let detail = match opts.field("detail")? {
906+ let defaults = default_tree_options(interp)?;
889− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(1),
907+ // a missing option falls back to its default, so an older config that
890− _ => return Err(Crash::new("treeWith: detail must be an Int")),
908+ // predates a newer option keeps working; a present option is type-checked
891− };
909+ let bool_opt = |name: &str, default: bool| -> Result<bool, Crash> {
892− let margin = match opts.field("margin")? {
893− Value::Bool(b) => b,
894− _ => return Err(Crash::new("treeWith: margin must be a Bool")),
896− let elide = match opts.field("elide")? {
910+ match opts.field(name) {
897− Value::Bool(b) => b,
911+ Ok(Value::Bool(b)) => Ok(b),
898− _ => return Err(Crash::new("treeWith: elide must be a Bool")),
912+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be a Bool", name))),
913+ Err(_) => Ok(default),
916+ let int_opt = |name: &str, default: i64| -> Result<i64, Crash> {
900− let icons = match opts.field("icons")? {
917+ match opts.field(name) {
901− Value::Bool(b) => b,
918+ Ok(Value::Int(n)) => Ok(n.to_string().parse::<i64>().unwrap_or(default)),
902− _ => return Err(Crash::new("treeWith: icons must be a Bool")),
919+ Ok(_) => Err(Crash::new(format!("treeWith: {} must be an Int", name))),
920+ Err(_) => Ok(default),
904− let color = opts.field("color")?.as_text()?.to_string();
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)?;
905− let lanes = match opts.field("lanes")? {
927+ let color = match opts.field("color") {
906− Value::Int(n) => n.to_string().parse::<i64>().unwrap_or(0),
928+ Ok(v) => v.as_text()?.to_string(),
907− _ => return Err(Crash::new("treeWith: lanes must be an Int")),
929+ Err(_) => defaults.color.clone(),
931+ let lanes = int_opt("lanes", defaults.lanes)?;
909 if lanes < 1 {
932 if lanes < 1 {
910 return Err(Crash::new("treeWith: lanes must be at least 1"));
933 return Err(Crash::new("treeWith: lanes must be at least 1"));
912− let author = match opts.field("author")? {
935+ let author = bool_opt("author", defaults.author)?;
913− Value::Bool(b) => b,
936+ let date = bool_opt("date", defaults.date)?;
914− _ => return Err(Crash::new("treeWith: author must be a Bool")),
916− let date = match opts.field("date")? {
917− Value::Bool(b) => b,
918− _ => return Err(Crash::new("treeWith: date must be a Bool")),
920− let files = match opts.field("files")? {
937+ let files = bool_opt("files", defaults.files)?;
921− Value::Bool(b) => b,
922− _ => return Err(Crash::new("treeWith: files must be a Bool")),
924 let pal = Palette {
938 let pal = Palette {
925 on: color_enabled(&color),
939 on: color_enabled(&color),
@@ -981,7 +995,6 @@ impl CommitInfo {
981fn build_info(
995fn build_info(
982 interp: &mut Interp,
996 interp: &mut Interp,
983 opts: &TreeOptions,
997 opts: &TreeOptions,
985 commit: &Value,
998 commit: &Value,
986 children: &[Value],
999 children: &[Value],
987 immutable: &BTreeSet<String>,
1000 immutable: &BTreeSet<String>,
@@ -990,6 +1003,7 @@ fn build_info(
990 parent_files: Option<&Value>,
1003 parent_files: Option<&Value>,
991 with_focus: bool,
1004 with_focus: bool,
992 parent_is_ancestor: bool,
1005 parent_is_ancestor: bool,
1006+ parent_is_focus: bool,
993) -> Result<CommitInfo, Crash> {
1007) -> Result<CommitInfo, Crash> {
994 let id = match commit.field("id")? {
1008 let id = match commit.field("id")? {
995 Value::Id(i) => i.to_string(),
1009 Value::Id(i) => i.to_string(),
@@ -1005,31 +1019,50 @@ fn build_info(
1005 let conflict = has_conflict(commit)?;
1019 let conflict = has_conflict(commit)?;
1006 let files = commit.field("files")?;
1020 let files = commit.field("files")?;
1007 let nfiles = files.as_list()?.len();
1021 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)
1032+ let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
1008 let (empty, size, detail_marks) = match parent_files {
1033 let (empty, size, detail_marks) = match parent_files {
1009 Some(pf) => {
1034 Some(pf) => {
1035+ // empty ⟺ the snapshot equals the parent's (id-based, no forcing)
1036+ let empty = crate::value::value_eq(&files, pf)?;
1010− let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1038+ let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1011− let marks = touched_paths(&change)?;
1039+ let marks = touched_paths(&change)?;
1012− let empty = marks.is_empty();
1013− // size: lines added+removed against the parent
1040+ // size: lines added+removed against the parent
1014− let from_map = snapshot_map_of(pf)?;
1041+ let from_map = snapshot_map_of(pf)?;
1015− let to_map = snapshot_map_of(&files)?;
1042+ let to_map = snapshot_map_of(&files)?;
1016− let mut lines = 0usize;
1043+ let mut lines = 0usize;
1017− for (p, _, _) in &marks {
1044+ for (p, _, _) in &marks {
1018− let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1045+ let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1019− let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1046+ let count = |m: &BTreeMap<Vec<String>, Value>| -> usize {
1020− match m.get(&key) {
1047+ match m.get(&key) {
1021− Some(Value::Blob(b)) => {
1048+ Some(Value::Blob(b)) => {
1022− let n = String::from_utf8_lossy(&b.bytes()).lines().count();
1051+ .map(|v| String::from_utf8_lossy(&v).lines().count())
1023− n.max(1)
1053+ n.max(1)
1028− lines += count(&from_map) + count(&to_map);
1058+ lines += count(&from_map) + count(&to_map);
1060+ let marks2: Vec<(String, char)> =
1061+ marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1062+ (marks.is_empty(), Some(lines), marks2)
1064+ (empty, None, Vec::new())
1030− let marks2: Vec<(String, char)> =
1031− marks.iter().map(|(p, m, _)| (p.clone(), *m)).collect();
1032− (empty, Some(lines), marks2)
1034 None => {
1067 None => {
1035 let n = files.as_list()?.len();
1068 let n = files.as_list()?.len();
@@ -1041,15 +1074,9 @@ fn build_info(
1041 let is_ancestor_of_focus = anc.contains(&id);
1074 let is_ancestor_of_focus = anc.contains(&id);
1042 let mut infos = Vec::new();
1075 let mut infos = Vec::new();
1043 for c in children.iter() {
1076 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(),
1049 infos.push(build_info(
1077 infos.push(build_info(
1053 &c.field("root")?,
1080 &c.field("root")?,
1054 c.field("children")?.as_list()?,
1081 c.field("children")?.as_list()?,
1055 immutable,
1082 immutable,
@@ -1058,6 +1085,7 @@ fn build_info(
1058 Some(&files),
1085 Some(&files),
1059 with_focus,
1086 with_focus,
1060 is_ancestor_of_focus,
1087 is_ancestor_of_focus,
1063 let immutable_flag = immutable.contains(&id);
1091 let immutable_flag = immutable.contains(&id);
@@ -1092,20 +1120,19 @@ fn tree_render(
1092 Value::Id(i) => i.to_string(),
1120 Value::Id(i) => i.to_string(),
1093 _ => String::new(),
1121 _ => String::new(),
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²))
1096 let mut anc: BTreeSet<String> = BTreeSet::new();
1126 let mut anc: BTreeSet<String> = BTreeSet::new();
1097− let mut cur = repo.clone();
1127+ if let Ok(Value::Id(i)) = repo.field("root").and_then(|r| r.field("id")) {
1099− let id = match cur.field("root")?.field("id")? {
1100− Value::Id(i) => i.to_string(),
1128+ anc.insert(i.to_string());
1104− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1130+ if let Ok(ctx) = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec())) {
1105− if ctx.is_empty() {
1131+ for frame in &ctx {
1106− break;
1132+ if let Ok(pid) = id_of_frame_parent(frame) {
1108− cur = crate::repo::by_id(&cur, &id_of_frame_parent(&ctx[0])?)?.unwrap();
1110 // the whole history, from the top
1137 // the whole history, from the top
1111 let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
1138 let top = crate::repo::by_id(repo, &top_id(repo)?)?.unwrap_or_else(|| repo.clone());
@@ -1114,7 +1141,6 @@ fn tree_render(
1114 let root_info = build_info(
1141 let root_info = build_info(
1118 &root_commit,
1144 &root_commit,
1119 top_children.as_list()?,
1145 top_children.as_list()?,
1120 &immutable,
1146 &immutable,
@@ -1123,6 +1149,7 @@ fn tree_render(
1124 with_focus,
1150 with_focus,
1127 // trunk T (§Trunk)
1154 // trunk T (§Trunk)
1128 let trunk = compute_trunk(interp, repo)?;
1155 let trunk = compute_trunk(interp, repo)?;
@@ -1182,17 +1209,16 @@ fn id_of_frame_parent(frame: &Value) -> Result<String, Crash> {
1184fn top_id(repo: &Value) -> Result<String, Crash> {
1211fn top_id(repo: &Value) -> Result<String, Crash> {
1185− let mut cur = repo.clone();
1212+ // the topmost ancestor's id: the focus's own id when the context is empty,
1186− loop {
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
1187− let ctx = cur.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1215+ let ctx = repo.field("context").and_then(|v| v.as_list().map(|x| x.to_vec()))?;
1188− if ctx.is_empty() {
1216+ match ctx.last() {
1189− return match cur.field("root")?.field("id")? {
1217+ None => match repo.field("root")?.field("id")? {
1190− Value::Id(i) => Ok(i.to_string()),
1218+ Value::Id(i) => Ok(i.to_string()),
1191− v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1219+ v => Err(Crash::new(format!("expected an Id, got a {}", v.kind_name()))),
1194− let pid = id_of_frame_parent(&ctx[0])?;
1221+ Some(frame) => id_of_frame_parent(frame),
1195− cur = crate::repo::by_id(&cur, &pid)?.unwrap();
@@ -1624,10 +1650,13 @@ fn build_row_text(
1624 } else {
1650 } else {
1625 String::new()
1651 String::new()
1627− // message
1653+ // message, coloured by state (§Colour): focus bold, conflict red, empty
1654+ // dim-italic; the collapsed marker is dim
1628 let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
1655 let msg_first = c.map(|x| x.message.lines().next().unwrap_or("")).unwrap_or("");
1629 let mut msg = match c {
1656 let mut msg = match c {
1630 Some(info) if info.empty => pal.dim_italic(msg_first),
1657 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),
1631 _ => msg_first.to_string(),
1660 _ => msg_first.to_string(),
1633 if let Display::Collapsed { hidden, .. } = n {
1662 if let Display::Collapsed { hidden, .. } = n {
@@ -1635,7 +1664,7 @@ fn build_row_text(
1635 if !msg.is_empty() {
1664 if !msg.is_empty() {
1636 msg.push_str(" ");
1665 msg.push_str(" ");
1638− msg.push_str(&format!("⋯ {}", hidden));
1667+ msg.push_str(&pal.dim(&format!("⋯ {}", hidden)));
1641 let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
1670 let labels = c.map(|x| x.labels.join(" ")).unwrap_or_default();
@@ -1755,7 +1784,12 @@ fn draw_rows(
1755 let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
1784 let (chars, lane0) = rail_row(rows, placements, idx, lanes_n, opts);
1756 let mut line = String::new();
1785 let mut line = String::new();
1757 line.push_str(&t.gutter);
1786 line.push_str(&t.gutter);
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))),
1758− line.push_str(&color_rails(&chars, &lane0, pal));
1792+ line.push_str(&color_rails(&chars, &lane0, pal, glyph));
1759 line.push(' ');
1793 line.push(' ');
1760 line.push_str(&t.id);
1794 line.push_str(&t.id);
1761 if !t.bar.is_empty() {
1795 if !t.bar.is_empty() {
@@ -1791,7 +1825,26 @@ fn draw_rows(
1791 line.push_str(&meta.join(" "));
1825 line.push_str(&meta.join(" "));
1793 let _ = pl;
1827 let _ = pl;
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()
1838+ let padded = match target_w {
1839+ Some(w) if width(&line) < w => {
1840+ format!("{}{}", line, " ".repeat(w - width(&line)))
1794− lines.push(line);
1844+ lines.push(pal.bg_line("48;5;236", &padded));
1795 // detail line (detail = 2, focus only)
1848 // detail line (detail = 2, focus only)
1796 if opts.detail >= 2
1849 if opts.detail >= 2
1797 && with_focus
1850 && with_focus
@@ -1801,7 +1854,7 @@ fn draw_rows(
1801 let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
1854 let (dchars, dlane0) = detail_rail_row(rows, placements, idx, lanes_n);
1802 let mut dline = String::new();
1855 let mut dline = String::new();
1803 dline.push_str(" ");
1856 dline.push_str(" ");
1804− dline.push_str(&color_rails(&dchars, &dlane0, pal));
1857+ dline.push_str(&color_rails(&dchars, &dlane0, pal, None));
1805 dline.push(' ');
1858 dline.push(' ');
1806 dline.push_str(&" ".repeat(id_w + 2));
1859 dline.push_str(&" ".repeat(id_w + 2));
1807 let marks: Vec<String> = c
1860 let marks: Vec<String> = c
@@ -2120,12 +2173,28 @@ fn detail_rail_row(
2120 (chars, lane0)
2173 (chars, lane0)
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.
2123−fn color_rails(chars: &[char], lane0: &[bool], pal: &Palette) -> String {
2179+fn color_rails(
2183+ glyph: Option<(usize, &str)>,
2124 let mut s = String::new();
2185 let mut s = String::new();
2125 for (i, c) in chars.iter().enumerate() {
2186 for (i, c) in chars.iter().enumerate() {
2126 let t = c.to_string();
2187 let t = c.to_string();
2127 if *c == ' ' {
2188 if *c == ' ' {
2128 s.push(*c);
2189 s.push(*c);
2190+ } else if let Some((pos, code)) = glyph {
2192+ s.push_str(&pal.code(code, &t));
2193+ } else if lane0[i] {
2194+ s.push_str(&pal.blue(&t));
2196+ s.push_str(&pal.dim(&t));
2129 } else if lane0[i] {
2198 } else if lane0[i] {
2130 s.push_str(&pal.blue(&t));
2199 s.push_str(&pal.blue(&t));
2131 } else {
2200 } else {
@@ -2282,6 +2351,26 @@ fn glyph_for(info: &CommitInfo, icons: bool) -> &'static str {
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 {
2359+ } else if info.conflict {
2360+ "31" // conflict: red
2361+ } else if info.empty {
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
2370+ "37" // other commit: light grey
2285fn size_bar(size: Option<usize>, pal: &Palette) -> String {
2374fn size_bar(size: Option<usize>, pal: &Palette) -> String {
2286 match size {
2375 match size {
2287 None | Some(0) => String::new(),
2376 None | Some(0) => String::new(),