commit 892c4dcc3c09ea5442f790f973dccd2cede241b2
parent 7ac7dc20993ddf89e6c3e0408eaf55a8ed959c14
Author: William Casarin <jb55@jb55.com>
Date: Mon, 23 Feb 2026 15:01:05 -0800
diff: add expandable context for local sessions
Read surrounding lines from disk when the user clicks
"Show more context above/below" buttons on Edit diffs.
Uses egui temp state to track expansion amounts per file,
incrementing by 3 lines per click. Gracefully degrades
(no buttons) for remote sessions, Write ops, or if the
file/old_string can't be found on disk.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
3 files changed, 170 insertions(+), 10 deletions(-)
diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs
@@ -1,5 +1,6 @@
use serde_json::Value;
use similar::{ChangeTag, TextDiff};
+use std::path::Path;
/// Represents a proposed file modification from an AI tool call
#[derive(Debug, Clone)]
@@ -45,6 +46,20 @@ impl From<ChangeTag> for DiffTag {
}
}
+/// Result of expanding diff context by reading the actual file from disk.
+pub struct ExpandedDiffContext {
+ /// Extra Equal lines loaded above the diff
+ pub above: Vec<DiffLine>,
+ /// Extra Equal lines loaded below the diff
+ pub below: Vec<DiffLine>,
+ /// 1-based line number in the file where the first displayed line starts
+ pub start_line: usize,
+ /// Whether there are more lines above that could be loaded
+ pub has_more_above: bool,
+ /// Whether there are more lines below that could be loaded
+ pub has_more_below: bool,
+}
+
impl FileUpdate {
/// Create a new FileUpdate, computing the diff eagerly
pub fn new(file_path: String, update_type: FileUpdateType) -> Self {
@@ -121,6 +136,65 @@ impl FileUpdate {
}
}
+ /// Read the file from disk and expand context around the edit.
+ ///
+ /// Returns `None` if this is not an Edit, the file can't be read,
+ /// or `old_string` can't be found in the file.
+ pub fn expanded_context(
+ &self,
+ extra_above: usize,
+ extra_below: usize,
+ ) -> Option<ExpandedDiffContext> {
+ let FileUpdateType::Edit { old_string, .. } = &self.update_type else {
+ return None;
+ };
+
+ let file_content = std::fs::read_to_string(Path::new(&self.file_path)).ok()?;
+
+ // Find where old_string appears in the file
+ let byte_offset = file_content.find(old_string.as_str())?;
+
+ // Count newlines before the match to get 0-based start line index
+ let start_idx = file_content[..byte_offset]
+ .chars()
+ .filter(|&c| c == '\n')
+ .count();
+
+ let file_lines: Vec<&str> = file_content.lines().collect();
+ let total_lines = file_lines.len();
+
+ let old_line_count = old_string.lines().count();
+ let end_idx = start_idx + old_line_count; // exclusive, 0-based
+
+ // Extra lines above
+ let above_start = start_idx.saturating_sub(extra_above);
+ let above: Vec<DiffLine> = file_lines[above_start..start_idx]
+ .iter()
+ .map(|line| DiffLine {
+ tag: DiffTag::Equal,
+ content: format!("{}\n", line),
+ })
+ .collect();
+
+ // Extra lines below
+ let below_end = (end_idx + extra_below).min(total_lines);
+ let below: Vec<DiffLine> = file_lines[end_idx..below_end]
+ .iter()
+ .map(|line| DiffLine {
+ tag: DiffTag::Equal,
+ content: format!("{}\n", line),
+ })
+ .collect();
+
+ Some(ExpandedDiffContext {
+ start_line: above_start + 1, // 1-based
+ has_more_above: above_start > 0,
+ has_more_below: below_end < total_lines,
+ above,
+ below,
+ })
+ }
+
/// Compute the diff lines for an update type (internal helper)
fn compute_diff_for(update_type: &FileUpdateType) -> Vec<DiffLine> {
match update_type {
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -592,8 +592,9 @@ impl<'a> DaveUi<'a> {
// Header with file path
diff::file_path_header(&file_update, ui);
- // Diff view
- diff::file_update_ui(&file_update, ui);
+ // Diff view (expand context only for local sessions)
+ let is_local = !self.flags.contains(DaveUiFlags::IsRemote);
+ diff::file_update_ui(&file_update, is_local, ui);
// Approve/deny buttons at the bottom left
ui.horizontal(|ui| {
diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs
@@ -5,25 +5,110 @@ use egui::{Color32, RichText, Ui};
const DELETE_COLOR: Color32 = Color32::from_rgb(200, 60, 60);
const INSERT_COLOR: Color32 = Color32::from_rgb(60, 180, 60);
const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128);
+const EXPAND_LINES_PER_CLICK: usize = 3;
+
+/// Render a file update diff view.
+///
+/// When `is_local` is true and the update is an Edit, expand-context
+/// buttons are shown at the top and bottom of the diff.
+pub fn file_update_ui(update: &FileUpdate, is_local: bool, ui: &mut Ui) {
+ let can_expand = is_local && matches!(update.update_type, FileUpdateType::Edit { .. });
+
+ // egui temp state for how many extra lines above/below
+ let expand_id = ui.id().with("diff_expand").with(&update.file_path);
+ let (extra_above, extra_below): (usize, usize) = if can_expand {
+ ui.data(|d| d.get_temp(expand_id).unwrap_or((0, 0)))
+ } else {
+ (0, 0)
+ };
+
+ // Try to compute expanded context from the file on disk
+ let expanded = if can_expand {
+ update.expanded_context(extra_above, extra_below)
+ } else {
+ None
+ };
-/// Render a file update diff view
-pub fn file_update_ui(update: &FileUpdate, ui: &mut Ui) {
egui::Frame::new()
.fill(ui.visuals().extreme_bg_color)
.inner_margin(8.0)
.corner_radius(4.0)
.show(ui, |ui| {
egui::ScrollArea::horizontal().show(ui, |ui| {
- render_diff_lines(update.diff_lines(), &update.update_type, ui);
+ if let Some(ctx) = &expanded {
+ // "Expand above" button
+ if ctx.has_more_above {
+ if expand_button(ui, true) {
+ ui.data_mut(|d| {
+ d.insert_temp(
+ expand_id,
+ (extra_above + EXPAND_LINES_PER_CLICK, extra_below),
+ );
+ });
+ }
+ }
+
+ // Build combined lines: above + core diff + below
+ let combined: Vec<&DiffLine> = ctx
+ .above
+ .iter()
+ .chain(update.diff_lines().iter())
+ .chain(ctx.below.iter())
+ .collect();
+
+ render_diff_lines(&combined, &update.update_type, ctx.start_line, ui);
+
+ // "Expand below" button
+ if ctx.has_more_below {
+ if expand_button(ui, false) {
+ ui.data_mut(|d| {
+ d.insert_temp(
+ expand_id,
+ (extra_above, extra_below + EXPAND_LINES_PER_CLICK),
+ );
+ });
+ }
+ }
+ } else {
+ // No expansion available: render as before (line numbers from 1)
+ let refs: Vec<&DiffLine> = update.diff_lines().iter().collect();
+ render_diff_lines(&refs, &update.update_type, 1, ui);
+ }
});
});
}
-/// Render the diff lines with proper coloring
-fn render_diff_lines(lines: &[DiffLine], update_type: &FileUpdateType, ui: &mut Ui) {
- // Track line numbers for old and new
- let mut old_line = 1usize;
- let mut new_line = 1usize;
+/// Render a clickable expand-context button. Returns true if clicked.
+fn expand_button(ui: &mut Ui, is_above: bool) -> bool {
+ let text = if is_above {
+ " \u{25B2} Show more context above"
+ } else {
+ " \u{25BC} Show more context below"
+ };
+ ui.add(
+ egui::Label::new(
+ RichText::new(text)
+ .monospace()
+ .size(11.0)
+ .color(LINE_NUMBER_COLOR),
+ )
+ .sense(egui::Sense::click()),
+ )
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .clicked()
+}
+
+/// Render the diff lines with proper coloring.
+///
+/// `start_line` is the 1-based file line number of the first displayed line.
+fn render_diff_lines(
+ lines: &[&DiffLine],
+ update_type: &FileUpdateType,
+ start_line: usize,
+ ui: &mut Ui,
+) {
+ let mut old_line = start_line;
+ let mut new_line = start_line;
for diff_line in lines {
ui.horizontal(|ui| {