Skip to content

Commit ce85904

Browse files
committed
feat(tui): add jump-to-dependent in detail pane
add bidirectional navigation in the detail pane. the "Blocks:" section now supports the same selection+jump as "Blocked by:". use [ and ] to switch between sections, 1-9 to select, Enter to jump. - add DetailSection enum (Deps/Dependents) and detail_dependent_selected - [ / ] keys switch active section when detail pane is focused - 1-9 and Enter dispatch to deps or dependents based on active section - active section shows numbered items; inactive shows plain bullets - dependent preview mirrors the existing dependency preview - auto-select Dependents section when issue has no deps but has dependents
1 parent e3ff710 commit ce85904

3 files changed

Lines changed: 395 additions & 20 deletions

File tree

src/tui/app.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ pub enum IssuesFocus {
109109
Details,
110110
}
111111

112+
/// which section is active in the detail pane.
113+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114+
pub enum DetailSection {
115+
/// "Blocked by:" (dependencies)
116+
#[default]
117+
Deps,
118+
/// "Blocks:" (dependents)
119+
Dependents,
120+
}
121+
112122
/// input mode for creating/editing issues.
113123
#[derive(Debug, Clone, PartialEq, Eq)]
114124
pub enum InputMode {
@@ -164,6 +174,10 @@ pub struct App {
164174
pub config: Config,
165175
/// selected dependency index in detail pane
166176
pub detail_dep_selected: Option<usize>,
177+
/// which section is active in the detail pane (deps or dependents)
178+
pub detail_section: DetailSection,
179+
/// selected dependent index in detail pane
180+
pub detail_dependent_selected: Option<usize>,
167181
/// filter query
168182
pub filter_query: String,
169183
/// status filter (empty means show all)
@@ -249,6 +263,8 @@ impl App {
249263
input_mode: InputMode::Normal,
250264
config,
251265
detail_dep_selected: None,
266+
detail_section: DetailSection::default(),
267+
detail_dependent_selected: None,
252268
filter_query: String::new(),
253269
status_filter: HashSet::new(),
254270
editor_file: None,
@@ -1141,6 +1157,58 @@ impl App {
11411157
}
11421158
}
11431159

1160+
/// select dependent by index (0-based).
1161+
pub fn select_dependent_by_index(&mut self, idx: usize) {
1162+
let Some(issue) = self.selected_issue() else {
1163+
return;
1164+
};
1165+
let dependents = crate::graph::get_dependents(issue.id(), &self.issues);
1166+
if idx < dependents.len() {
1167+
self.detail_dependent_selected = Some(idx);
1168+
self.message = None;
1169+
}
1170+
}
1171+
1172+
/// open the selected dependent in the list view.
1173+
pub fn open_selected_dependent(&mut self) {
1174+
let dependent_id = {
1175+
let Some(issue) = self.selected_issue() else {
1176+
self.message = Some("no issue selected".to_string());
1177+
return;
1178+
};
1179+
let dependents = crate::graph::get_dependents(issue.id(), &self.issues);
1180+
if dependents.is_empty() {
1181+
self.message = Some("no dependents".to_string());
1182+
return;
1183+
}
1184+
let Some(dep_idx) = self.detail_dependent_selected else {
1185+
self.message = Some("no dependent selected".to_string());
1186+
return;
1187+
};
1188+
1189+
// sort dependents the same way as rendering: open/doing first
1190+
let mut sorted = dependents;
1191+
sorted.sort_by_key(|id| {
1192+
self.issues
1193+
.get(id)
1194+
.map(|iss| matches!(iss.status(), Status::Done | Status::Skip) as u8)
1195+
.unwrap_or(0)
1196+
});
1197+
1198+
let Some(id) = sorted.get(dep_idx) else {
1199+
self.message = Some("dependent not found".to_string());
1200+
return;
1201+
};
1202+
id.to_string()
1203+
};
1204+
1205+
if self.select_issue_by_id(&dependent_id) {
1206+
self.message = None;
1207+
} else {
1208+
self.message = Some("dependent issue missing".to_string());
1209+
}
1210+
}
1211+
11441212
// start/done keybindings removed - too easy to trigger accidentally
11451213
// use CLI commands (brd start, brd done) instead
11461214

@@ -1288,6 +1356,27 @@ impl App {
12881356
} else {
12891357
self.detail_dep_selected = Some(0);
12901358
}
1359+
1360+
// reset dependent selection
1361+
let dependents_len = self
1362+
.selected_issue()
1363+
.map(|issue| crate::graph::get_dependents(issue.id(), &self.issues).len())
1364+
.unwrap_or(0);
1365+
if dependents_len == 0 {
1366+
self.detail_dependent_selected = None;
1367+
} else {
1368+
self.detail_dependent_selected = Some(0);
1369+
}
1370+
1371+
// reset section: prefer deps if available, otherwise dependents
1372+
if deps_len > 0 {
1373+
self.detail_section = DetailSection::Deps;
1374+
} else if dependents_len > 0 {
1375+
self.detail_section = DetailSection::Dependents;
1376+
} else {
1377+
self.detail_section = DetailSection::Deps;
1378+
}
1379+
12911380
// also reset detail scroll when changing issue
12921381
self.detail_scroll = 0;
12931382
}
@@ -1307,6 +1396,31 @@ impl App {
13071396
} else {
13081397
self.detail_dep_selected = Some(0);
13091398
}
1399+
1400+
// clamp dependent selection
1401+
let dependents_len = self
1402+
.selected_issue()
1403+
.map(|issue| crate::graph::get_dependents(issue.id(), &self.issues).len())
1404+
.unwrap_or(0);
1405+
if dependents_len == 0 {
1406+
self.detail_dependent_selected = None;
1407+
} else if let Some(idx) = self.detail_dependent_selected {
1408+
if idx >= dependents_len {
1409+
self.detail_dependent_selected = Some(dependents_len - 1);
1410+
}
1411+
} else {
1412+
self.detail_dependent_selected = Some(0);
1413+
}
1414+
1415+
// if the active section has no items, switch to the other section
1416+
if self.detail_section == DetailSection::Deps && deps_len == 0 && dependents_len > 0 {
1417+
self.detail_section = DetailSection::Dependents;
1418+
} else if self.detail_section == DetailSection::Dependents
1419+
&& dependents_len == 0
1420+
&& deps_len > 0
1421+
{
1422+
self.detail_section = DetailSection::Deps;
1423+
}
13101424
}
13111425

13121426
fn select_issue_by_id(&mut self, issue_id: &str) -> bool {
@@ -1988,6 +2102,38 @@ mod tests {
19882102
assert_eq!(app.detail_dep_selected, Some(2));
19892103
}
19902104

2105+
#[test]
2106+
fn test_reload_preserves_dependent_selection() {
2107+
let env = TestEnv::new();
2108+
env.add_issue("brd-main", "main issue", Priority::P1, Status::Open);
2109+
env.add_issue_with_deps(
2110+
"brd-dep1",
2111+
"dependent one",
2112+
Priority::P2,
2113+
Status::Open,
2114+
vec!["brd-main"],
2115+
);
2116+
env.add_issue_with_deps(
2117+
"brd-dep2",
2118+
"dependent two",
2119+
Priority::P3,
2120+
Status::Open,
2121+
vec!["brd-main"],
2122+
);
2123+
2124+
let mut app = env.app();
2125+
assert_eq!(app.selected_issue_id(), Some("brd-main"));
2126+
assert_eq!(app.detail_dependent_selected, Some(0));
2127+
2128+
// select dependent index 1
2129+
app.select_dependent_by_index(1);
2130+
assert_eq!(app.detail_dependent_selected, Some(1));
2131+
2132+
// reload issues - should preserve dependent selection
2133+
app.reload_issues(&env.paths).expect("reload failed");
2134+
assert_eq!(app.detail_dependent_selected, Some(1));
2135+
}
2136+
19912137
#[test]
19922138
fn test_reload_clamps_dep_selection_when_deps_removed() {
19932139
let env = TestEnv::new();
@@ -2018,4 +2164,63 @@ mod tests {
20182164
app.reload_issues(&env.paths).expect("reload failed");
20192165
assert_eq!(app.detail_dep_selected, Some(0));
20202166
}
2167+
2168+
#[test]
2169+
fn test_reload_clamps_dependent_selection_when_dependents_removed() {
2170+
let env = TestEnv::new();
2171+
env.add_issue("brd-main", "main issue", Priority::P1, Status::Open);
2172+
env.add_issue_with_deps(
2173+
"brd-dep1",
2174+
"dependent one",
2175+
Priority::P2,
2176+
Status::Open,
2177+
vec!["brd-main"],
2178+
);
2179+
env.add_issue_with_deps(
2180+
"brd-dep2",
2181+
"dependent two",
2182+
Priority::P3,
2183+
Status::Open,
2184+
vec!["brd-main"],
2185+
);
2186+
2187+
let mut app = env.app();
2188+
assert_eq!(app.selected_issue_id(), Some("brd-main"));
2189+
2190+
// select dependent index 1
2191+
app.select_dependent_by_index(1);
2192+
assert_eq!(app.detail_dependent_selected, Some(1));
2193+
2194+
// remove one dependent by removing its dep on brd-main
2195+
let issue_path = env.paths.issues_dir(&env.config).join("brd-dep2.md");
2196+
let mut issue = app.issues.get("brd-dep2").unwrap().clone();
2197+
issue.frontmatter.deps = vec![];
2198+
issue.save(&issue_path).expect("failed to save");
2199+
2200+
// reload - should clamp to max valid index (0)
2201+
app.reload_issues(&env.paths).expect("reload failed");
2202+
assert_eq!(app.detail_dependent_selected, Some(0));
2203+
}
2204+
2205+
#[test]
2206+
fn test_reset_dep_selection_sets_section_to_dependents_when_no_deps() {
2207+
let env = TestEnv::new();
2208+
// brd-main has no deps but is depended on by brd-dep1
2209+
env.add_issue("brd-main", "main issue", Priority::P1, Status::Open);
2210+
env.add_issue_with_deps(
2211+
"brd-dep1",
2212+
"dependent",
2213+
Priority::P2,
2214+
Status::Open,
2215+
vec!["brd-main"],
2216+
);
2217+
2218+
let app = env.app();
2219+
assert_eq!(app.selected_issue_id(), Some("brd-main"));
2220+
2221+
// no deps, so section should default to Dependents
2222+
assert_eq!(app.detail_section, DetailSection::Dependents);
2223+
assert_eq!(app.detail_dep_selected, None);
2224+
assert_eq!(app.detail_dependent_selected, Some(0));
2225+
}
20212226
}

src/tui/event.rs

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::time::Duration;
44

55
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
66

7-
use super::app::{App, InputMode, IssuesFocus, View};
7+
use super::app::{App, DetailSection, InputMode, IssuesFocus, View};
88
use crate::error::Result;
99
use crate::repo::RepoPaths;
1010

@@ -302,12 +302,27 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
302302
app.agents_focus = crate::tui::app::AgentsFocus::Files;
303303
}
304304

305-
// 1-9 selects dependency by number (issues view, detail focused)
305+
// [ and ] switch detail section (issues view, detail focused)
306+
KeyCode::Char('[')
307+
if app.view == View::Issues && app.issues_focus == IssuesFocus::Details =>
308+
{
309+
app.detail_section = DetailSection::Deps;
310+
}
311+
KeyCode::Char(']')
312+
if app.view == View::Issues && app.issues_focus == IssuesFocus::Details =>
313+
{
314+
app.detail_section = DetailSection::Dependents;
315+
}
316+
317+
// 1-9 selects dep or dependent by number (issues view, detail focused)
306318
KeyCode::Char(c @ '1'..='9')
307319
if app.view == View::Issues && app.issues_focus == IssuesFocus::Details =>
308320
{
309321
let idx = (c as usize) - ('1' as usize);
310-
app.select_dep_by_index(idx);
322+
match app.detail_section {
323+
DetailSection::Deps => app.select_dep_by_index(idx),
324+
DetailSection::Dependents => app.select_dependent_by_index(idx),
325+
}
311326
}
312327

313328
// actions
@@ -333,8 +348,11 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
333348
}
334349
}
335350
IssuesFocus::Details => {
336-
// when details pane is focused, open selected dependency
337-
app.open_selected_dependency();
351+
// when details pane is focused, open selected dep or dependent
352+
match app.detail_section {
353+
DetailSection::Deps => app.open_selected_dependency(),
354+
DetailSection::Dependents => app.open_selected_dependent(),
355+
}
338356
}
339357
},
340358
_ => {}
@@ -920,4 +938,83 @@ mod tests {
920938
assert!(app.show_detail_overlay);
921939
assert_eq!(app.selected, initial_selected);
922940
}
941+
942+
#[test]
943+
fn test_section_switching_with_brackets() {
944+
let env = TestEnv::new();
945+
env.add_issue("brd-dep1", "dep one", Priority::P2, Status::Open);
946+
env.add_issue_with_deps(
947+
"brd-main",
948+
"main issue",
949+
Priority::P1,
950+
Status::Open,
951+
vec!["brd-dep1".to_string()],
952+
);
953+
954+
let mut app = env.app();
955+
assert_eq!(app.selected_issue_id(), Some("brd-main"));
956+
957+
// switch focus to detail pane
958+
handle_key_event(&mut app, &env.paths, key(KeyCode::Tab)).expect("tab failed");
959+
assert_eq!(app.issues_focus, IssuesFocus::Details);
960+
961+
// default section is Deps
962+
assert_eq!(app.detail_section, crate::tui::app::DetailSection::Deps);
963+
964+
// ] switches to Dependents
965+
handle_key_event(&mut app, &env.paths, key(KeyCode::Char(']'))).expect("] failed");
966+
assert_eq!(
967+
app.detail_section,
968+
crate::tui::app::DetailSection::Dependents
969+
);
970+
971+
// [ switches back to Deps
972+
handle_key_event(&mut app, &env.paths, key(KeyCode::Char('['))).expect("[ failed");
973+
assert_eq!(app.detail_section, crate::tui::app::DetailSection::Deps);
974+
}
975+
976+
#[test]
977+
fn test_dependent_selection_and_open() {
978+
let env = TestEnv::new();
979+
// brd-dep1 depends on brd-main, so brd-main blocks brd-dep1
980+
env.add_issue("brd-main", "main issue", Priority::P1, Status::Open);
981+
env.add_issue_with_deps(
982+
"brd-dep1",
983+
"dependent one",
984+
Priority::P2,
985+
Status::Open,
986+
vec!["brd-main".to_string()],
987+
);
988+
env.add_issue_with_deps(
989+
"brd-dep2",
990+
"dependent two",
991+
Priority::P3,
992+
Status::Open,
993+
vec!["brd-main".to_string()],
994+
);
995+
996+
let mut app = env.app();
997+
assert_eq!(app.selected_issue_id(), Some("brd-main"));
998+
999+
// switch focus to detail pane
1000+
handle_key_event(&mut app, &env.paths, key(KeyCode::Tab)).expect("tab failed");
1001+
assert_eq!(app.issues_focus, IssuesFocus::Details);
1002+
1003+
// switch to dependents section
1004+
handle_key_event(&mut app, &env.paths, key(KeyCode::Char(']'))).expect("] failed");
1005+
assert_eq!(
1006+
app.detail_section,
1007+
crate::tui::app::DetailSection::Dependents
1008+
);
1009+
1010+
// select dependent 2 by pressing '2'
1011+
handle_key_event(&mut app, &env.paths, key(KeyCode::Char('2')))
1012+
.expect("select dependent failed");
1013+
assert_eq!(app.detail_dependent_selected, Some(1));
1014+
1015+
// press Enter to jump to the dependent
1016+
handle_key_event(&mut app, &env.paths, key(KeyCode::Enter)).expect("open dependent failed");
1017+
// should have jumped to one of the dependents
1018+
assert_ne!(app.selected_issue_id(), Some("brd-main"));
1019+
}
9231020
}

0 commit comments

Comments
 (0)