Skip to content

Commit 0bb10bb

Browse files
Alan Shumclaude
andcommitted
fix: show only group-relevant hunks when selecting file in sidebar (v0.5.1)
Previously, clicking a file in the sidebar showed all hunks from the entire group (or the wrong group when a file appeared in multiple groups). Now the diff pane shows only the specific hunks assigned to that file within its group. Key changes: - TreeNodeId::File includes group index to disambiguate same file across groups - Sidebar navigation (j/k) syncs diff view immediately, not just on Enter - hunk_filter_for_file builds per-file filter scoped to the selected group Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2873ab4 commit 0bb10bb

File tree

4 files changed

+88
-51
lines changed

4 files changed

+88
-51
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "semantic-diff"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
edition = "2021"
55
description = "A terminal diff viewer with AI-powered semantic grouping (Claude CLI / Copilot)"
66
license = "MIT"

src/app.rs

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -526,44 +526,45 @@ impl App {
526526
match key.code {
527527
KeyCode::Char('j') | KeyCode::Down => {
528528
ts.key_down();
529-
None
530529
}
531530
KeyCode::Char('k') | KeyCode::Up => {
532531
ts.key_up();
533-
None
534532
}
535533
KeyCode::Left => {
536534
ts.key_left();
537-
None
538535
}
539536
KeyCode::Right => {
540537
ts.key_right();
541-
None
542538
}
543539
KeyCode::Enter => {
544-
let selected = ts.selected().to_vec();
545-
drop(ts); // release borrow before mutating self
546-
if let Some(last) = selected.last() {
547-
match last {
548-
TreeNodeId::File(path) => {
549-
self.select_tree_file(path);
550-
}
551-
TreeNodeId::Group(gi) => {
552-
self.select_tree_group(*gi);
553-
}
554-
}
555-
}
556-
None
540+
ts.toggle_selected();
557541
}
558542
KeyCode::Char('g') => {
559543
ts.select_first();
560-
None
561544
}
562545
KeyCode::Char('G') => {
563546
ts.select_last();
564-
None
565547
}
566-
_ => None,
548+
_ => return None,
549+
}
550+
551+
// After any navigation, sync the diff view to the selected tree node
552+
let selected = ts.selected().to_vec();
553+
drop(ts); // release borrow before mutating self
554+
self.apply_tree_selection(&selected);
555+
None
556+
}
557+
558+
/// Update the diff view filter based on the currently selected tree node.
559+
fn apply_tree_selection(&mut self, selected: &[TreeNodeId]) {
560+
match selected.last() {
561+
Some(TreeNodeId::File(group_idx, path)) => {
562+
self.select_tree_file(path, *group_idx);
563+
}
564+
Some(TreeNodeId::Group(gi)) => {
565+
self.select_tree_group(*gi);
566+
}
567+
None => {}
567568
}
568569
}
569570

@@ -625,11 +626,11 @@ impl App {
625626
}
626627
}
627628

628-
/// Filter the diff view to the group containing the selected file, and scroll to it.
629-
/// If the group is already active, just scroll to the file without toggling off.
630-
fn select_tree_file(&mut self, path: &str) {
631-
let filter = self.hunk_filter_for_file(path);
632-
// Always apply the group filter (don't toggle — that's what group headers are for)
629+
/// Filter the diff view to show only the hunks for the selected file within its group.
630+
/// `group_idx` identifies which group the file was selected from (None = flat/ungrouped).
631+
fn select_tree_file(&mut self, path: &str, group_idx: Option<usize>) {
632+
let filter = self.hunk_filter_for_file(path, group_idx);
633+
// Always apply the filter (don't toggle — that's what group headers are for)
633634
self.tree_filter = Some(filter);
634635
// Rebuild visible items and scroll to the selected file's header
635636
let items = self.visible_items();
@@ -652,40 +653,74 @@ impl App {
652653
fn select_tree_group(&mut self, group_idx: usize) {
653654
let filter = self.hunk_filter_for_group(group_idx);
654655
if filter.is_empty() {
655-
self.tree_state.borrow_mut().toggle_selected();
656656
return;
657657
}
658-
// Toggle: if already filtering to this group, clear it
659-
if self.tree_filter.as_ref() == Some(&filter) {
660-
self.tree_filter = None;
661-
} else {
662-
self.tree_filter = Some(filter);
663-
}
658+
self.tree_filter = Some(filter);
664659
self.ui_state.selected_index = 0;
665660
self.ui_state.scroll_offset = 0;
666661
}
667662

668-
/// Build a HunkFilter for the group containing `path`.
669-
/// Falls back to showing just that file (all hunks) if no groups exist.
670-
fn hunk_filter_for_file(&self, path: &str) -> HunkFilter {
663+
/// Build a HunkFilter for a single file's hunks within a specific group.
664+
/// Only shows the hunks relevant to that group, not the entire group's files.
665+
fn hunk_filter_for_file(&self, path: &str, group_idx: Option<usize>) -> HunkFilter {
671666
if let Some(groups) = &self.semantic_groups {
672-
for (gi, group) in groups.iter().enumerate() {
673-
let has_file = group.changes().iter().any(|c| {
674-
c.file == path || path.ends_with(c.file.as_str()) || c.file.ends_with(path)
675-
});
676-
if has_file {
677-
return self.hunk_filter_for_group(gi);
667+
if let Some(gi) = group_idx {
668+
if gi >= groups.len() {
669+
// "Other" group — extract only this file's ungrouped hunks
670+
return self.hunk_filter_for_file_in_other(path);
671+
}
672+
if let Some(group) = groups.get(gi) {
673+
if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
674+
return filter;
675+
}
678676
}
679677
}
680-
// File is in the "Other" group — collect ungrouped file/hunks
681-
return self.hunk_filter_for_other();
678+
// Fallback (no group_idx or file not found in specified group):
679+
// search all groups for the first match
680+
for group in groups.iter() {
681+
if let Some(filter) = self.hunk_filter_for_file_in_group(path, group) {
682+
return filter;
683+
}
684+
}
685+
return self.hunk_filter_for_file_in_other(path);
682686
}
683-
// No semantic groups — filter to just this file (all hunks)
687+
// No semantic groups — show all hunks for this file
684688
let mut filter = HunkFilter::new();
685689
filter.insert(path.to_string(), HashSet::new());
686690
filter
687691
}
688692

693+
/// Build a single-file HunkFilter from a specific group's changes.
694+
fn hunk_filter_for_file_in_group(
695+
&self,
696+
path: &str,
697+
group: &crate::grouper::SemanticGroup,
698+
) -> Option<HunkFilter> {
699+
for change in &group.changes() {
700+
if let Some(diff_path) = self.resolve_diff_path(&change.file) {
701+
if diff_path == path {
702+
let mut filter = HunkFilter::new();
703+
let hunk_set: HashSet<usize> = change.hunks.iter().copied().collect();
704+
filter.insert(diff_path, hunk_set);
705+
return Some(filter);
706+
}
707+
}
708+
}
709+
None
710+
}
711+
712+
/// Build a single-file HunkFilter from the "Other" (ungrouped) hunks.
713+
fn hunk_filter_for_file_in_other(&self, path: &str) -> HunkFilter {
714+
let other = self.hunk_filter_for_other();
715+
let mut filter = HunkFilter::new();
716+
if let Some(hunk_set) = other.get(path) {
717+
filter.insert(path.to_string(), hunk_set.clone());
718+
} else {
719+
filter.insert(path.to_string(), HashSet::new());
720+
}
721+
filter
722+
}
723+
689724
/// Build a HunkFilter for group at `group_idx`.
690725
fn hunk_filter_for_group(&self, group_idx: usize) -> HunkFilter {
691726
if let Some(groups) = &self.semantic_groups {

src/ui/file_tree.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ use ratatui::Frame;
88
use tui_tree_widget::{Tree, TreeItem};
99

1010
/// Identifier for tree nodes.
11+
/// Files include an optional group index to disambiguate the same file in multiple groups.
1112
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
1213
pub enum TreeNodeId {
1314
Group(usize),
14-
File(String),
15+
File(Option<usize>, String), // (group_index, path)
1516
}
1617

1718
impl std::fmt::Display for TreeNodeId {
1819
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1920
match self {
2021
TreeNodeId::Group(i) => write!(f, "group-{i}"),
21-
TreeNodeId::File(path) => write!(f, "file-{path}"),
22+
TreeNodeId::File(Some(gi), path) => write!(f, "file-{gi}-{path}"),
23+
TreeNodeId::File(None, path) => write!(f, "file-{path}"),
2224
}
2325
}
2426
}
@@ -50,7 +52,7 @@ fn build_flat_tree<'a>(app: &App) -> Vec<TreeItem<'a, TreeNodeId>> {
5052
Style::default().fg(Color::Red),
5153
),
5254
]);
53-
TreeItem::new_leaf(TreeNodeId::File(path), line)
55+
TreeItem::new_leaf(TreeNodeId::File(None, path), line)
5456
})
5557
.collect()
5658
}
@@ -130,7 +132,7 @@ fn build_grouped_tree<'a>(
130132
Style::default().fg(Color::Red),
131133
),
132134
]);
133-
children.push(TreeItem::new_leaf(TreeNodeId::File(path), line));
135+
children.push(TreeItem::new_leaf(TreeNodeId::File(Some(gi), path), line));
134136
}
135137
}
136138

@@ -194,7 +196,7 @@ fn build_grouped_tree<'a>(
194196
Style::default().fg(Color::Red),
195197
),
196198
]);
197-
other_children.push(TreeItem::new_leaf(TreeNodeId::File(path), line));
199+
other_children.push(TreeItem::new_leaf(TreeNodeId::File(Some(groups.len()), path), line));
198200
}
199201
}
200202

0 commit comments

Comments
 (0)