notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_dave/src/file_update.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 5+++--
Mcrates/notedeck_dave/src/ui/diff.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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| {