Skip to content

Commit 33ec562

Browse files
joskeclaude
andcommitted
Dir view: single-pane selection, keyboard nav, and crash fixes
- Use one SingleSelection + NoSelection model swap so only the focused pane shows a selection highlight - Left/Right arrow keys switch focus between panes - Focused pane gets accent border (dir-pane-focused CSS) - Left pane auto-focused on window open - scroll_to(FOCUS) after model swap and reload to fix keyboard cursor - Guard selection sync during reload_dir to prevent SIGSEGV - Reuse reload_dir in file watcher (was doing raw remove_all) - Skip rebuild when scan results unchanged - Keyboard shortcuts (Alt+Left/Right, Delete) work via window-level action group Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f76c34 commit 33ec562

1 file changed

Lines changed: 150 additions & 85 deletions

File tree

src/ui.rs

Lines changed: 150 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const CSS: &str = r"
4949
.find-bar { background: alpha(@theme_bg_color, 0.95); border-top: 1px solid @borders; padding: 4px 6px; }
5050
.find-bar entry { min-height: 28px; }
5151
.goto-entry { min-height: 28px; }
52+
.dir-pane-focused { border: 2px solid @accent_color; border-radius: 4px; }
5253
";
5354

5455
const SEP: char = '\x1f';
@@ -5322,9 +5323,13 @@ fn build_dir_window(
53225323
}
53235324
});
53245325

5326+
// Shared selection model — only the focused pane uses it;
5327+
// the other pane uses NoSelection so no highlight is shown.
5328+
let dir_sel = SingleSelection::new(Some(tree_model.clone()));
5329+
let no_sel = gtk4::NoSelection::new(Some(tree_model.clone()));
5330+
53255331
// ── Left pane ──────────────────────────────────────────────────
5326-
let left_sel = SingleSelection::new(Some(tree_model.clone()));
5327-
let left_view = ColumnView::new(Some(left_sel.clone()));
5332+
let left_view = ColumnView::new(Some(dir_sel.clone()));
53285333
left_view.set_show_column_separators(true);
53295334
{
53305335
let col = ColumnViewColumn::new(Some("Name"), Some(make_name_factory(true)));
@@ -5340,8 +5345,7 @@ fn build_dir_window(
53405345
}
53415346

53425347
// ── Right pane ─────────────────────────────────────────────────
5343-
let right_sel = SingleSelection::new(Some(tree_model.clone()));
5344-
let right_view = ColumnView::new(Some(right_sel.clone()));
5348+
let right_view = ColumnView::new(Some(no_sel.clone()));
53455349
right_view.set_show_column_separators(true);
53465350
{
53475351
let col = ColumnViewColumn::new(Some("Name"), Some(make_name_factory(false)));
@@ -5358,19 +5362,86 @@ fn build_dir_window(
53585362
right_view.append_column(&col);
53595363
}
53605364

5361-
// Track which pane was last focused (true = left, false = right)
5365+
// Track which pane was last focused (true = left, false = right).
5366+
// Swap models so only the focused pane shows selection.
53625367
let focused_left = Rc::new(Cell::new(true));
5368+
let sel_syncing = Rc::new(Cell::new(false));
5369+
left_view.add_css_class("dir-pane-focused");
53635370
{
53645371
let fl = focused_left.clone();
5372+
let lv = left_view.clone();
5373+
let rv = right_view.clone();
5374+
let sel = dir_sel.clone();
5375+
let ns = no_sel.clone();
53655376
let fc = EventControllerFocus::new();
5366-
fc.connect_enter(move |_| fl.set(true));
5377+
fc.connect_enter(move |_| {
5378+
if fl.get() {
5379+
return;
5380+
}
5381+
fl.set(true);
5382+
lv.add_css_class("dir-pane-focused");
5383+
rv.remove_css_class("dir-pane-focused");
5384+
lv.set_model(Some(&sel));
5385+
rv.set_model(Some(&ns));
5386+
let pos = sel.selected();
5387+
let v = lv.clone();
5388+
gtk4::glib::idle_add_local_once(move || {
5389+
v.scroll_to(pos, None, gtk4::ListScrollFlags::FOCUS, None);
5390+
});
5391+
});
53675392
left_view.add_controller(fc);
53685393
let fl = focused_left.clone();
5394+
let lv = left_view.clone();
5395+
let rv = right_view.clone();
5396+
let sel = dir_sel.clone();
5397+
let ns = no_sel.clone();
53695398
let fc = EventControllerFocus::new();
5370-
fc.connect_enter(move |_| fl.set(false));
5399+
fc.connect_enter(move |_| {
5400+
if !fl.get() {
5401+
return;
5402+
}
5403+
fl.set(false);
5404+
rv.add_css_class("dir-pane-focused");
5405+
lv.remove_css_class("dir-pane-focused");
5406+
rv.set_model(Some(&sel));
5407+
lv.set_model(Some(&ns));
5408+
let pos = sel.selected();
5409+
let v = rv.clone();
5410+
gtk4::glib::idle_add_local_once(move || {
5411+
v.scroll_to(pos, None, gtk4::ListScrollFlags::FOCUS, None);
5412+
});
5413+
});
53715414
right_view.add_controller(fc);
53725415
}
53735416

5417+
// Left/Right arrow keys switch between panes
5418+
{
5419+
let rv = right_view.clone();
5420+
let kc = EventControllerKey::new();
5421+
kc.set_propagation_phase(gtk4::PropagationPhase::Capture);
5422+
kc.connect_key_pressed(move |_, key, _, _| {
5423+
if key == gtk4::gdk::Key::Right {
5424+
rv.grab_focus();
5425+
gtk4::glib::Propagation::Stop
5426+
} else {
5427+
gtk4::glib::Propagation::Proceed
5428+
}
5429+
});
5430+
left_view.add_controller(kc);
5431+
let lv = left_view.clone();
5432+
let kc = EventControllerKey::new();
5433+
kc.set_propagation_phase(gtk4::PropagationPhase::Capture);
5434+
kc.connect_key_pressed(move |_, key, _, _| {
5435+
if key == gtk4::gdk::Key::Left {
5436+
lv.grab_focus();
5437+
gtk4::glib::Propagation::Stop
5438+
} else {
5439+
gtk4::glib::Propagation::Proceed
5440+
}
5441+
});
5442+
right_view.add_controller(kc);
5443+
}
5444+
53745445
// ScrolledWindows + Paned
53755446
let left_scroll = ScrolledWindow::builder()
53765447
.hscrollbar_policy(PolicyType::Automatic)
@@ -5449,18 +5520,19 @@ fn build_dir_window(
54495520
let rd = right_dir.clone();
54505521
let st = settings.clone();
54515522
let tm = tree_model.clone();
5452-
let ls = left_sel.clone();
5453-
let rsel = right_sel.clone();
5523+
let sel = dir_sel.clone();
5524+
let syncing = sel_syncing.clone();
5525+
let lv = left_view.clone();
5526+
let rv = right_view.clone();
54545527
move || {
5455-
// Save selected rel_paths
5456-
let sel_rel = |sel: &SingleSelection| -> Option<String> {
5457-
let pos = sel.selected();
5458-
let row = tm.item(pos)?.downcast::<TreeListRow>().ok()?;
5528+
// Suppress selection sync during rebuild
5529+
syncing.set(true);
5530+
let saved_pos = sel.selected();
5531+
let saved_rel = (|| -> Option<String> {
5532+
let row = tm.item(saved_pos)?.downcast::<TreeListRow>().ok()?;
54595533
let obj = row.item().and_downcast::<StringObject>()?;
54605534
Some(decode_rel_path(&obj.string()).to_string())
5461-
};
5462-
let left_rel = sel_rel(&ls);
5463-
let right_rel = sel_rel(&rsel);
5535+
})();
54645536

54655537
// Save expanded rel_paths
54665538
let mut expanded: Vec<String> = Vec::new();
@@ -5482,7 +5554,24 @@ fn build_dir_window(
54825554
&mut new_map,
54835555
&st.borrow().dir_filters,
54845556
);
5557+
// Skip rebuild if root-level data hasn't changed
5558+
let root_changed = new_store.n_items() != rs.n_items()
5559+
|| (0..new_store.n_items()).any(|i| {
5560+
new_store
5561+
.item(i)
5562+
.and_downcast::<StringObject>()
5563+
.map(|o| o.string())
5564+
!= rs
5565+
.item(i)
5566+
.and_downcast::<StringObject>()
5567+
.map(|o| o.string())
5568+
});
5569+
// Always update children_map so re-expanded dirs show fresh data
54855570
*cm.borrow_mut() = new_map;
5571+
if !root_changed {
5572+
syncing.set(false);
5573+
return;
5574+
}
54865575
rs.remove_all();
54875576
for i in 0..new_store.n_items() {
54885577
if let Some(obj) = new_store.item(i) {
@@ -5504,22 +5593,34 @@ fn build_dir_window(
55045593
}
55055594
}
55065595

5507-
// Restore selection by rel_path
5508-
let restore_sel = |sel: &SingleSelection, target: &Option<String>| {
5509-
if let Some(ref rel) = *target {
5510-
for i in 0..tm.n_items() {
5511-
if let Some(row) = tm.item(i).and_then(|o| o.downcast::<TreeListRow>().ok())
5512-
&& let Some(obj) = row.item().and_downcast::<StringObject>()
5513-
&& decode_rel_path(&obj.string()) == rel.as_str()
5514-
{
5515-
sel.set_selected(i);
5516-
return;
5517-
}
5596+
// Restore selection on both panes
5597+
let n = tm.n_items();
5598+
let mut final_pos = saved_pos.min(if n > 0 { n - 1 } else { 0 });
5599+
if let Some(ref rel) = saved_rel {
5600+
for i in 0..n {
5601+
if let Some(row) = tm.item(i).and_then(|o| o.downcast::<TreeListRow>().ok())
5602+
&& let Some(obj) = row.item().and_downcast::<StringObject>()
5603+
&& decode_rel_path(&obj.string()) == rel.as_str()
5604+
{
5605+
final_pos = i;
5606+
break;
55185607
}
55195608
}
5520-
};
5521-
restore_sel(&ls, &left_rel);
5522-
restore_sel(&rsel, &right_rel);
5609+
}
5610+
if n > 0 {
5611+
sel.set_selected(final_pos);
5612+
}
5613+
syncing.set(false);
5614+
5615+
// Defer scroll_to until after GTK has laid out the new rows,
5616+
// otherwise the ColumnView hasn't realized the items yet.
5617+
let lv2 = lv.clone();
5618+
let rv2 = rv.clone();
5619+
gtk4::glib::idle_add_local_once(move || {
5620+
let flags = gtk4::ListScrollFlags::FOCUS;
5621+
lv2.scroll_to(final_pos, None, flags, None);
5622+
rv2.scroll_to(final_pos, None, flags, None);
5623+
});
55235624
}
55245625
};
55255626

@@ -5536,14 +5637,11 @@ fn build_dir_window(
55365637
});
55375638
}
55385639

5539-
// Helper: get selected row's encoded data from the focused pane
5640+
// Helper: get selected row's encoded data
55405641
let get_selected_row = {
55415642
let tm = tree_model.clone();
5542-
let ls = left_sel.clone();
5543-
let rs = right_sel.clone();
5544-
let fl = focused_left.clone();
5643+
let sel = dir_sel.clone();
55455644
move || -> Option<String> {
5546-
let sel = if fl.get() { &ls } else { &rs };
55475645
let pos = sel.selected();
55485646
let item = tm.item(pos)?;
55495647
let row = item.downcast::<TreeListRow>().ok()?;
@@ -5871,61 +5969,26 @@ fn build_dir_window(
58715969
}
58725970

58735971
// Poll for filesystem changes and reload
5874-
let cm_reload = children_map.clone();
5875-
let rs_reload = root_store.clone();
58765972
let tabs_reload = open_tabs.clone();
58775973
let ld_reload = left_dir.clone();
58785974
let rd_reload = right_dir.clone();
5879-
let st_reload = settings.clone();
5880-
gtk4::glib::timeout_add_local(Duration::from_millis(500), move || {
5881-
let mut changed = false;
5882-
while fs_rx.try_recv().is_ok() {
5883-
changed = true;
5884-
}
5885-
if changed && !SAVING.load(std::sync::atomic::Ordering::Relaxed) {
5886-
// Build new tree into a temporary map (not inside borrow_mut)
5887-
let mut new_map = HashMap::new();
5888-
let (new_store, _) = scan_level(
5889-
Path::new(ld_reload.borrow().as_str()),
5890-
Path::new(rd_reload.borrow().as_str()),
5891-
"",
5892-
&mut new_map,
5893-
&st_reload.borrow().dir_filters,
5894-
);
5895-
// Only rebuild the tree if root-level data actually changed.
5896-
// remove_all()+re-append destroys expansion and selection state,
5897-
// so we skip it when the scan results are identical.
5898-
let root_changed = new_store.n_items() != rs_reload.n_items()
5899-
|| (0..new_store.n_items()).any(|i| {
5900-
new_store
5901-
.item(i)
5902-
.and_downcast::<StringObject>()
5903-
.map(|o| o.string())
5904-
!= rs_reload
5905-
.item(i)
5906-
.and_downcast::<StringObject>()
5907-
.map(|o| o.string())
5908-
});
5909-
// Always update children_map so re-expanded dirs show fresh data.
5910-
*cm_reload.borrow_mut() = new_map;
5911-
if root_changed {
5912-
// Drop children_map borrow before touching the store.
5913-
// Appending to root_store triggers TreeListModel's create_func which
5914-
// borrows children_map immutably — so we must not hold a mutable borrow.
5915-
rs_reload.remove_all();
5916-
for i in 0..new_store.n_items() {
5917-
if let Some(obj) = new_store.item(i) {
5918-
rs_reload.append(&obj.downcast::<StringObject>().unwrap());
5919-
}
5920-
}
5975+
{
5976+
let reload = reload_dir.clone();
5977+
gtk4::glib::timeout_add_local(Duration::from_millis(500), move || {
5978+
let mut changed = false;
5979+
while fs_rx.try_recv().is_ok() {
5980+
changed = true;
59215981
}
5922-
// Reload open file tabs
5923-
for tab in tabs_reload.borrow().iter() {
5924-
reload_file_tab(tab, &ld_reload.borrow(), &rd_reload.borrow());
5982+
if changed && !SAVING.load(std::sync::atomic::Ordering::Relaxed) {
5983+
reload();
5984+
// Reload open file tabs
5985+
for tab in tabs_reload.borrow().iter() {
5986+
reload_file_tab(tab, &ld_reload.borrow(), &rd_reload.borrow());
5987+
}
59255988
}
5926-
}
5927-
gtk4::glib::ControlFlow::Continue
5928-
});
5989+
gtk4::glib::ControlFlow::Continue
5990+
});
5991+
}
59295992

59305993
// Window title
59315994
let left_name = Path::new(left_dir.borrow().as_str())
@@ -5962,6 +6025,7 @@ fn build_dir_window(
59626025
win_actions.add_action(&action);
59636026
}
59646027
window.insert_action_group("win", Some(&win_actions));
6028+
window.insert_action_group("dir", Some(&dir_action_group));
59656029

59666030
// Register keyboard accelerators
59676031
if let Some(gtk_app) = window.application() {
@@ -5982,4 +6046,5 @@ fn build_dir_window(
59826046
}
59836047

59846048
window.present();
6049+
left_view.grab_focus();
59856050
}

0 commit comments

Comments
 (0)