@@ -191,7 +191,9 @@ fn display_id(interp: &Interp, id: &str, pal: &Palette) -> String {
193fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash> {
193fn display_line(interp: &Interp, v: &Value, pal: &Palette) -> Result<String, Crash> {
194+ let v = &v.forced()?;
194 match v {
195 match v {
196+ Value::Thunk(_) => unreachable!("forced never returns a thunk"),
195 Value::Int(n) => Ok(n.to_string()),
197 Value::Int(n) => Ok(n.to_string()),
196 Value::Bool(b) => Ok(b.to_string()),
198 Value::Bool(b) => Ok(b.to_string()),
197 Value::Text(t) => {
199 Value::Text(t) => {
@@ -463,7 +465,9 @@ fn display_block(
463 out: &mut String,
465 out: &mut String,
464) -> Result<(), Crash> {
466) -> Result<(), Crash> {
465 let pad = " ".repeat(indent);
467 let pad = " ".repeat(indent);
468+ let v = &v.forced()?;
466 match v {
469 match v {
470+ Value::Thunk(_) => unreachable!("forced never returns a thunk"),
467 Value::Text(t) => {
471 Value::Text(t) => {
468 out.push_str(t);
472 out.push_str(t);
469 return Ok(());
473 return Ok(());
@@ -1016,11 +1020,13 @@ fn build_info(
1017 .map(|l| l.as_text().map(|s| s.to_string()))
1021 .map(|l| l.as_text().map(|s| s.to_string()))
1018 .collect::<Result<_, _>>()?;
1022 .collect::<Result<_, _>>()?;
1019− let conflict = has_conflict(commit)?;
1023+ // conflict and empty are O(1) backend queries when the backend can answer
1020− let files = commit.field("files")?;
1024+ // them from tree ids (jj), without touching the file list at all; the
1021− let nfiles = files.as_list()?.len();
1025+ // in-memory backend cannot, so fall back to walking the files
1022− // `empty` (no change against the parent) is needed at every detail level;
1026+ let conflict = match interp.backend.has_conflict(&id) {
1023− // compute it by id-based snapshot equality, which never forces blob bytes.
1027+ Some(c) => c,
1028+ None => has_conflict(commit)?,
1024 // `size` (a line count) and `detail_marks` are only rendered for detail ≥
1030 // `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
1031 // 2 and for the focus and its neighbours, so skip the per-commit line
1026 // counting elsewhere — it dominates render time on large histories.
1032 // counting elsewhere — it dominates render time on large histories.
@@ -1030,16 +1036,29 @@ fn build_info(
1030 matches!(c.field("root").and_then(|r| r.field("id")), Ok(Value::Id(i)) if *i == focus_id)
1036 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;
1038 let want_diff = opts.detail >= 2 || is_focus_pre || child_is_focus || parent_is_focus;
1039+ let backend_empty = interp.backend.is_empty(&id);
1040+ // the files list is only materialized when something needs it: the diff
1041+ // (detail/focus), the files data column, or the emptiness fallback
1042+ let need_files = want_diff || opts.files || backend_empty.is_none();
1043+ let files = if need_files {
1044+ Some(commit.field("files")?)
1048+ let nfiles = files.as_ref().map(|f| f.as_list().map(|l| l.len())).transpose()?.unwrap_or(0);
1033 let (empty, size, detail_marks) = match parent_files {
1049 let (empty, size, detail_marks) = match parent_files {
1034 Some(pf) => {
1050 Some(pf) => {
1035− // empty ⟺ the snapshot equals the parent's (id-based, no forcing)
1051+ let empty = match backend_empty {
1036− let empty = crate::value::value_eq(&files, pf)?;
1053+ None => crate::value::value_eq(files.as_ref().expect("files loaded"), pf)?,
1037 if want_diff {
1055 if want_diff {
1056+ let files = files.as_ref().expect("files loaded");
1038 let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1057 let change = Value::record(&[("from", pf.clone()), ("to", files.clone())]);
1039 let marks = touched_paths(&change)?;
1058 let marks = touched_paths(&change)?;
1040 // size: lines added+removed against the parent
1059 // size: lines added+removed against the parent
1041 let from_map = snapshot_map_of(pf)?;
1060 let from_map = snapshot_map_of(pf)?;
1042− let to_map = snapshot_map_of(&files)?;
1061+ let to_map = snapshot_map_of(files)?;
1043 let mut lines = 0usize;
1062 let mut lines = 0usize;
1044 for (p, _, _) in &marks {
1063 for (p, _, _) in &marks {
1045 let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
1064 let key: Vec<String> = p.split('/').map(|s| s.to_string()).collect();
@@ -1065,7 +1084,7 @@ fn build_info(
1067 None => {
1086 None => {
1068− let n = files.as_list()?.len();
1087+ let n = nfiles;
1069 (n == 0 && id != ROOT_ID, None, Vec::new())
1088 (n == 0 && id != ROOT_ID, None, Vec::new())
@@ -1082,13 +1101,12 @@ fn build_info(
1082 immutable,
1101 immutable,
1083 focus_id,
1102 focus_id,
1085− Some(&files),
1104+ files.as_ref(),
1086 with_focus,
1105 with_focus,
1087 is_ancestor_of_focus,
1106 is_ancestor_of_focus,
1088 is_focus_pre,
1107 is_focus_pre,
1091− let immutable_flag = immutable.contains(&id);
1109+ } let immutable_flag = immutable.contains(&id);
1092 Ok(CommitInfo {
1110 Ok(CommitInfo {
1094 message,
1112 message,