#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq)]
pub struct DirRowInfo {
pub status: FileStatus,
pub name: String,
pub is_dir: bool,
pub rel_path: String,
pub left_size: Option<u64>,
pub left_mtime: Option<SystemTime>,
pub right_size: Option<u64>,
pub right_mtime: Option<SystemTime>,
}
impl DirRowInfo {
pub fn encode(&self) -> String {
let s = self.status.code();
let d = if self.is_dir { "1" } else { "0" };
let ls = self.left_size.map(format_size).unwrap_or_default();
let lm = self.left_mtime.map(format_mtime).unwrap_or_default();
let rs = self.right_size.map(format_size).unwrap_or_default();
let rm = self.right_mtime.map(format_mtime).unwrap_or_default();
format!(
"{s}{SEP}{name}{SEP}{d}{SEP}{rel}{SEP}{ls}{SEP}{lm}{SEP}{rs}{SEP}{rm}",
name = self.name,
rel = self.rel_path
)
}
pub fn decode(raw: &str) -> Self {
let mut parts = raw.splitn(8, SEP);
let status_code = parts.next().unwrap_or("S");
let name = parts.next().unwrap_or("").to_string();
let is_dir = parts.next().unwrap_or("0") == "1";
let rel_path = parts.next().unwrap_or("").to_string();
let status = match status_code {
"D" => FileStatus::Different,
"L" => FileStatus::LeftOnly,
"R" => FileStatus::RightOnly,
_ => FileStatus::Same,
};
DirRowInfo {
status,
name,
is_dir,
rel_path,
left_size: None, left_mtime: None,
right_size: None,
right_mtime: None,
}
}
pub fn get_field_text(raw: &str, index: usize) -> &str {
raw.splitn(8, SEP).nth(index).unwrap_or("")
}
}
fn copy_path_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
if src.is_file() {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)?;
} else if src.is_dir() {
for entry in walkdir::WalkDir::new(src) {
let entry = entry.map_err(std::io::Error::other)?;
let Ok(rel) = entry.path().strip_prefix(src) else {
continue;
};
let target = dst.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target)?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target)?;
}
}
}
Ok(())
}
fn read_dir_entries(
dir: &Path,
dir_filters: &[String],
hide_hidden: bool,
) -> BTreeMap<String, DirMeta> {
let mut map = BTreeMap::new();
if let Ok(rd) = fs::read_dir(dir) {
for entry in rd.filter_map(Result::ok) {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if dir_filters.iter().any(|f| f == &name) {
continue;
}
if hide_hidden && name.starts_with('.') {
continue;
}
let meta = entry.metadata().ok();
let is_dir = entry.path().is_dir();
map.insert(
name,
DirMeta {
size: meta.as_ref().map(std::fs::Metadata::len),
mtime: meta.as_ref().and_then(|m| m.modified().ok()),
is_dir,
},
);
}
}
map
}
struct ScanEntry {
status: FileStatus,
name: String,
is_dir: bool,
rel_path: String,
left_size: Option<u64>,
left_mtime: Option<SystemTime>,
right_size: Option<u64>,
right_mtime: Option<SystemTime>,
children: Vec<ScanEntry>,
}
fn scan_tree(
left_root: &Path,
right_root: &Path,
rel: &str,
dir_filters: &[String],
hide_hidden: bool,
) -> (Vec<ScanEntry>, FileStatus) {
let left_dir = if rel.is_empty() {
left_root.to_path_buf()
} else {
left_root.join(rel)
};
let right_dir = if rel.is_empty() {
right_root.to_path_buf()
} else {
right_root.join(rel)
};
let left_entries = read_dir_entries(&left_dir, dir_filters, hide_hidden);
let right_entries = read_dir_entries(&right_dir, dir_filters, hide_hidden);
let all: BTreeSet<&String> = left_entries.keys().chain(right_entries.keys()).collect();
let mut names: Vec<(&String, bool)> = all
.iter()
.map(|n| {
let is_dir = left_entries.get(*n).is_some_and(|m| m.is_dir)
|| right_entries.get(*n).is_some_and(|m| m.is_dir);
(*n, is_dir)
})
.collect();
names.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
let mut entries = Vec::new();
let mut all_same = true;
for (name, is_dir) in &names {
let in_left = left_entries.contains_key(*name);
let in_right = right_entries.contains_key(*name);
let child_rel = if rel.is_empty() {
(*name).clone()
} else {
format!("{rel}/{name}")
};
let (status, children);
if *is_dir {
let (child_entries, child_agg) =
scan_tree(left_root, right_root, &child_rel, dir_filters, hide_hidden);
status = if !in_left {
FileStatus::RightOnly
} else if !in_right {
FileStatus::LeftOnly
} else {
child_agg
};
children = child_entries;
} else {
children = Vec::new();
status = match (in_left, in_right) {
(true, false) => FileStatus::LeftOnly,
(false, true) => FileStatus::RightOnly,
(true, true) => {
let lm = left_entries.get(*name);
let rm = right_entries.get(*name);
if lm.and_then(|m| m.size) == rm.and_then(|m| m.size) {
let lc = fs::read(left_dir.join(name)).unwrap_or_default();
let rc = fs::read(right_dir.join(name)).unwrap_or_default();
if lc == rc {
FileStatus::Same
} else {
FileStatus::Different
}
} else {
FileStatus::Different
}
}
_ => unreachable!(),
};
}
if status != FileStatus::Same {
all_same = false;
}
let lm = left_entries.get(*name);
let rm = right_entries.get(*name);
entries.push(ScanEntry {
status,
name: (*name).clone(),
is_dir: *is_dir,
rel_path: child_rel,
left_size: lm.and_then(|m| m.size),
left_mtime: lm.and_then(|m| m.mtime),
right_size: rm.and_then(|m| m.size),
right_mtime: rm.and_then(|m| m.mtime),
children,
});
}
let agg = if all_same {
FileStatus::Same
} else {
FileStatus::Different
};
(entries, agg)
}
fn build_stores(entries: &[ScanEntry], children_map: &mut HashMap<String, ListStore>) -> ListStore {
let store = ListStore::new::<StringObject>();
for entry in entries {
if entry.is_dir {
let child_store = build_stores(&entry.children, children_map);
children_map.insert(entry.rel_path.clone(), child_store);
}
let info = DirRowInfo {
status: entry.status,
name: entry.name.clone(),
is_dir: entry.is_dir,
rel_path: entry.rel_path.clone(),
left_size: entry.left_size,
left_mtime: entry.left_mtime,
right_size: entry.right_size,
right_mtime: entry.right_mtime,
};
store.append(&StringObject::new(&info.encode()));
}
store
}
fn apply_status_class(widget: &impl WidgetExt, status: &str, is_left: bool) {
for cls in &[
"diff-changed",
"diff-deleted",
"diff-inserted",
"diff-missing",
] {
widget.remove_css_class(cls);
}
if is_left {
match status {
"D" => widget.add_css_class("diff-changed"),
"L" => widget.add_css_class("diff-deleted"),
"R" => widget.add_css_class("diff-missing"),
_ => {}
}
} else {
match status {
"D" => widget.add_css_class("diff-changed"),
"R" => widget.add_css_class("diff-inserted"),
"L" => widget.add_css_class("diff-missing"),
_ => {}
}
}
}
fn make_name_factory(is_left: bool) -> SignalListItemFactory {
let factory = SignalListItemFactory::new();
factory.connect_setup(|_, list_item| {
let item = list_item.downcast_ref::<ListItem>().unwrap();
let expander = TreeExpander::new();
let hbox = GtkBox::new(Orientation::Horizontal, 4);
let icon = Image::new();
let label = Label::new(None);
label.set_halign(gtk4::Align::Start);
label.set_hexpand(true);
hbox.append(&icon);
hbox.append(&label);
expander.set_child(Some(&hbox));
item.set_child(Some(&expander));
});
factory.connect_bind(move |_, list_item| {
let item = list_item.downcast_ref::<ListItem>().unwrap();
let row = item.item().and_downcast::<TreeListRow>().unwrap();
let obj = row.item().and_downcast::<StringObject>().unwrap();
let raw = obj.string();
let expander = item.child().and_downcast::<TreeExpander>().unwrap();
expander.set_list_row(Some(&row));
let hbox = expander.child().and_downcast::<GtkBox>().unwrap();
let icon = hbox.first_child().and_downcast::<Image>().unwrap();
let label = icon.next_sibling().and_downcast::<Label>().unwrap();
let info = DirRowInfo::decode(&raw);
icon.set_icon_name(Some(if info.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
label.set_label(&info.name);
apply_status_class(&label, info.status.code(), is_left);
});
factory
}
fn make_field_factory(is_left: bool, field_idx: usize) -> SignalListItemFactory {
let factory = SignalListItemFactory::new();
factory.connect_setup(|_, list_item| {
let item = list_item.downcast_ref::<ListItem>().unwrap();
let label = Label::new(None);
label.set_halign(gtk4::Align::End);
item.set_child(Some(&label));
});
factory.connect_bind(move |_, list_item| {
let item = list_item.downcast_ref::<ListItem>().unwrap();
let row = item.item().and_downcast::<TreeListRow>().unwrap();
let obj = row.item().and_downcast::<StringObject>().unwrap();
let raw = obj.string();
let label = item.child().and_downcast::<Label>().unwrap();
let info = DirRowInfo::decode(&raw);
let missing = (is_left && info.status == FileStatus::RightOnly)
|| (!is_left && info.status == FileStatus::LeftOnly);
label.set_label(if missing {
""
} else {
DirRowInfo::get_field_text(&raw, field_idx)
});
apply_status_class(&label, info.status.code(), is_left);
});
factory
}
pub(super) fn build_dir_window(
app: &Application,
left_dir: std::path::PathBuf,
right_dir: std::path::PathBuf,
settings: Rc<RefCell<Settings>>,
) {
let left_dir = Rc::new(RefCell::new(left_dir.to_string_lossy().into_owned()));
let right_dir = Rc::new(RefCell::new(right_dir.to_string_lossy().into_owned()));
let children_map = Rc::new(RefCell::new(HashMap::new()));
let root_store = ListStore::new::<StringObject>();
{
let ld = left_dir.borrow().clone();
let rd = right_dir.borrow().clone();
let filters = settings.borrow().dir_filters.clone();
let hide_hidden = settings.borrow().hide_hidden_files;
let cm = children_map.clone();
let rs = root_store.clone();
gtk4::glib::spawn_future_local(async move {
let (entries, _) = gio::spawn_blocking(move || {
scan_tree(Path::new(&ld), Path::new(&rd), "", &filters, hide_hidden)
})
.await
.unwrap();
let store = build_stores(&entries, &mut cm.borrow_mut());
for i in 0..store.n_items() {
if let Some(obj) = store.item(i) {
rs.append(&obj.downcast::<StringObject>().unwrap());
}
}
});
}
let cm = children_map.clone();
let tree_model = TreeListModel::new(root_store.clone(), false, false, move |item| {
let obj = item.downcast_ref::<StringObject>()?;
let raw = obj.string();
let info = DirRowInfo::decode(&raw);
if info.is_dir {
cm.borrow()
.get(&info.rel_path)
.cloned()
.map(gio::prelude::Cast::upcast::<gio::ListModel>)
} else {
None
}
});
let dir_sel = SingleSelection::new(Some(tree_model.clone()));
let left_view = ColumnView::new(Some(dir_sel.clone()));
left_view.set_show_column_separators(true);
{
let col = ColumnViewColumn::new(Some("Name"), Some(make_name_factory(true)));
col.set_expand(true);
left_view.append_column(&col);
let col = ColumnViewColumn::new(Some("Size"), Some(make_field_factory(true, 4)));
col.set_fixed_width(80);
left_view.append_column(&col);
let col =
ColumnViewColumn::new(Some("Modification time"), Some(make_field_factory(true, 5)));
col.set_fixed_width(180);
left_view.append_column(&col);
}
let right_view = ColumnView::new(Some(dir_sel.clone()));
right_view.set_opacity(0.55);
right_view.set_show_column_separators(true);
{
let col = ColumnViewColumn::new(Some("Name"), Some(make_name_factory(false)));
col.set_expand(true);
right_view.append_column(&col);
let col = ColumnViewColumn::new(Some("Size"), Some(make_field_factory(false, 6)));
col.set_fixed_width(80);
right_view.append_column(&col);
let col = ColumnViewColumn::new(
Some("Modification time"),
Some(make_field_factory(false, 7)),
);
col.set_fixed_width(180);
right_view.append_column(&col);
}
let focused_left = Rc::new(Cell::new(true));
let sel_syncing = Rc::new(Cell::new(false));
let left_scroll_slot: Rc<RefCell<Option<ScrolledWindow>>> = Rc::new(RefCell::new(None));
let right_scroll_slot: Rc<RefCell<Option<ScrolledWindow>>> = Rc::new(RefCell::new(None));
{
let fl = focused_left.clone();
let lv = left_view.clone();
let rv = right_view.clone();
let sel = dir_sel.clone();
let ls = left_scroll_slot.clone();
let rs = right_scroll_slot.clone();
let fc = EventControllerFocus::new();
fc.connect_enter(move |_| {
if fl.get() {
return;
}
fl.set(true);
lv.set_opacity(1.0);
rv.set_opacity(0.55);
if let Some(sw) = ls.borrow().as_ref() {
sw.add_css_class("dir-pane-focused");
sw.remove_css_class("dir-pane-inactive");
}
if let Some(sw) = rs.borrow().as_ref() {
sw.remove_css_class("dir-pane-focused");
sw.add_css_class("dir-pane-inactive");
}
let pos = sel.selected();
let vadj = lv
.ancestor(ScrolledWindow::static_type())
.and_downcast::<ScrolledWindow>()
.map(|sw| (sw.vadjustment().value(), sw));
let v = lv.clone();
gtk4::glib::idle_add_local_once(move || {
v.scroll_to(pos, None, gtk4::ListScrollFlags::FOCUS, None);
if let Some((val, sw)) = vadj {
let sw2 = sw.clone();
gtk4::glib::idle_add_local_once(move || {
sw2.vadjustment().set_value(val);
});
}
});
});
left_view.add_controller(fc);
let fl = focused_left.clone();
let lv = left_view.clone();
let rv = right_view.clone();
let sel = dir_sel.clone();
let ls = left_scroll_slot.clone();
let rs = right_scroll_slot.clone();
let fc = EventControllerFocus::new();
fc.connect_enter(move |_| {
if !fl.get() {
return;
}
fl.set(false);
rv.set_opacity(1.0);
lv.set_opacity(0.55);
if let Some(sw) = rs.borrow().as_ref() {
sw.add_css_class("dir-pane-focused");
sw.remove_css_class("dir-pane-inactive");
}
if let Some(sw) = ls.borrow().as_ref() {
sw.remove_css_class("dir-pane-focused");
sw.add_css_class("dir-pane-inactive");
}
let pos = sel.selected();
let vadj = rv
.ancestor(ScrolledWindow::static_type())
.and_downcast::<ScrolledWindow>()
.map(|sw| (sw.vadjustment().value(), sw));
let v = rv.clone();
gtk4::glib::idle_add_local_once(move || {
v.scroll_to(pos, None, gtk4::ListScrollFlags::FOCUS, None);
if let Some((val, sw)) = vadj {
let sw2 = sw.clone();
gtk4::glib::idle_add_local_once(move || {
sw2.vadjustment().set_value(val);
});
}
});
});
right_view.add_controller(fc);
}
{
let rv = right_view.clone();
let kc = EventControllerKey::new();
kc.set_propagation_phase(gtk4::PropagationPhase::Capture);
kc.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Right {
rv.grab_focus();
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
left_view.add_controller(kc);
let lv = left_view.clone();
let kc = EventControllerKey::new();
kc.set_propagation_phase(gtk4::PropagationPhase::Capture);
kc.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Left {
lv.grab_focus();
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
right_view.add_controller(kc);
}
let left_scroll = ScrolledWindow::builder()
.hscrollbar_policy(PolicyType::Automatic)
.min_content_width(360)
.child(&left_view)
.build();
let right_scroll = ScrolledWindow::builder()
.hscrollbar_policy(PolicyType::Automatic)
.min_content_width(360)
.child(&right_view)
.build();
left_scroll.add_css_class("dir-pane-focused");
right_scroll.add_css_class("dir-pane-inactive");
*left_scroll_slot.borrow_mut() = Some(left_scroll.clone());
*right_scroll_slot.borrow_mut() = Some(right_scroll.clone());
{
let syncing = Rc::new(Cell::new(false));
let rs = right_scroll.clone();
let s = syncing.clone();
left_scroll.vadjustment().connect_value_changed(move |adj| {
if !s.get() {
s.set(true);
rs.vadjustment().set_value(adj.value());
s.set(false);
}
});
let ls = left_scroll.clone();
let s = syncing;
right_scroll
.vadjustment()
.connect_value_changed(move |adj| {
if !s.get() {
s.set(true);
ls.vadjustment().set_value(adj.value());
s.set(false);
}
});
}
let left_header = Label::new(Some(&shortened_path(Path::new(&*left_dir.borrow()))));
left_header.set_tooltip_text(Some(&*left_dir.borrow()));
left_header.set_ellipsize(gtk4::pango::EllipsizeMode::Start);
left_header.set_hexpand(true);
left_header.set_margin_start(6);
left_header.set_margin_end(6);
left_header.set_margin_top(4);
left_header.set_margin_bottom(4);
let right_header = Label::new(Some(&shortened_path(Path::new(&*right_dir.borrow()))));
right_header.set_tooltip_text(Some(&*right_dir.borrow()));
right_header.set_ellipsize(gtk4::pango::EllipsizeMode::Start);
right_header.set_hexpand(true);
right_header.set_margin_start(6);
right_header.set_margin_end(6);
right_header.set_margin_top(4);
right_header.set_margin_bottom(4);
let left_pane_box = GtkBox::new(Orientation::Vertical, 0);
left_pane_box.append(&left_header);
left_scroll.set_vexpand(true);
left_pane_box.append(&left_scroll);
let right_pane_box = GtkBox::new(Orientation::Vertical, 0);
right_pane_box.append(&right_header);
right_scroll.set_vexpand(true);
right_pane_box.append(&right_scroll);
let dir_paned = Paned::new(Orientation::Horizontal);
dir_paned.set_start_child(Some(&left_pane_box));
dir_paned.set_end_child(Some(&right_pane_box));
let copy_left_btn = Button::from_icon_name("go-previous-symbolic");
copy_left_btn.set_tooltip_text(Some("Copy to left (Alt+Left)"));
let copy_right_btn = Button::from_icon_name("go-next-symbolic");
copy_right_btn.set_tooltip_text(Some("Copy to right (Alt+Right)"));
let delete_btn = Button::from_icon_name("user-trash-symbolic");
delete_btn.set_tooltip_text(Some("Delete selected (Delete)"));
let dir_copy_box = GtkBox::new(Orientation::Horizontal, 0);
dir_copy_box.add_css_class("linked");
dir_copy_box.append(©_left_btn);
dir_copy_box.append(©_right_btn);
let dir_toolbar = GtkBox::new(Orientation::Horizontal, 8);
dir_toolbar.set_margin_start(6);
dir_toolbar.set_margin_end(6);
dir_toolbar.set_margin_top(4);
dir_toolbar.set_margin_bottom(4);
let dir_swap_btn = Button::from_icon_name("object-flip-horizontal-symbolic");
dir_swap_btn.set_tooltip_text(Some("Swap panes"));
let dir_prefs_btn = Button::from_icon_name("preferences-system-symbolic");
dir_prefs_btn.set_tooltip_text(Some("Preferences (Ctrl+,)"));
dir_prefs_btn.set_action_name(Some("win.prefs"));
dir_toolbar.append(&dir_copy_box);
dir_toolbar.append(&delete_btn);
dir_toolbar.append(&dir_swap_btn);
dir_toolbar.append(&dir_prefs_btn);
let scan_loading = Rc::new(Cell::new(false));
let reload_dir = {
let cm = children_map.clone();
let rs = root_store.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let st = settings.clone();
let tm = tree_model.clone();
let sel = dir_sel.clone();
let syncing = sel_syncing.clone();
let lv = left_view.clone();
let rv = right_view.clone();
let loading = scan_loading.clone();
move || {
if loading.get() {
return;
}
loading.set(true);
syncing.set(true);
let saved_pos = sel.selected();
let saved_rel = (|| -> Option<String> {
let row = tm.item(saved_pos)?.downcast::<TreeListRow>().ok()?;
let obj = row.item().and_downcast::<StringObject>()?;
Some(DirRowInfo::decode(&obj.string()).rel_path)
})();
let mut expanded: Vec<String> = Vec::new();
for i in 0..tm.n_items() {
if let Some(row) = tm.item(i).and_then(|o| o.downcast::<TreeListRow>().ok())
&& row.is_expanded()
&& let Some(obj) = row.item().and_downcast::<StringObject>()
{
expanded.push(DirRowInfo::decode(&obj.string()).rel_path);
}
}
let ld_str = ld.borrow().clone();
let rd_str = rd.borrow().clone();
let filters = st.borrow().dir_filters.clone();
let hide_hidden = st.borrow().hide_hidden_files;
let cm = cm.clone();
let rs = rs.clone();
let tm = tm.clone();
let sel = sel.clone();
let syncing = syncing.clone();
let lv = lv.clone();
let rv = rv.clone();
let loading = loading.clone();
gtk4::glib::spawn_future_local(async move {
let (entries, _) = gio::spawn_blocking(move || {
scan_tree(
Path::new(&ld_str),
Path::new(&rd_str),
"",
&filters,
hide_hidden,
)
})
.await
.unwrap();
loading.set(false);
let mut new_map = HashMap::new();
let new_store = build_stores(&entries, &mut new_map);
let root_changed = new_store.n_items() != rs.n_items()
|| (0..new_store.n_items()).any(|i| {
new_store
.item(i)
.and_downcast::<StringObject>()
.map(|o| o.string())
!= rs
.item(i)
.and_downcast::<StringObject>()
.map(|o| o.string())
});
*cm.borrow_mut() = new_map;
if !root_changed {
syncing.set(false);
return;
}
rs.remove_all();
for i in 0..new_store.n_items() {
if let Some(obj) = new_store.item(i) {
rs.append(&obj.downcast::<StringObject>().unwrap());
}
}
for rel in &expanded {
for i in 0..tm.n_items() {
if let Some(row) = tm.item(i).and_then(|o| o.downcast::<TreeListRow>().ok())
&& let Some(obj) = row.item().and_downcast::<StringObject>()
&& DirRowInfo::decode(&obj.string()).rel_path == rel.as_str()
{
row.set_expanded(true);
break;
}
}
}
let n = tm.n_items();
let mut final_pos = saved_pos.min(if n > 0 { n - 1 } else { 0 });
if let Some(ref rel) = saved_rel {
for i in 0..n {
if let Some(row) = tm.item(i).and_then(|o| o.downcast::<TreeListRow>().ok())
&& let Some(obj) = row.item().and_downcast::<StringObject>()
&& DirRowInfo::decode(&obj.string()).rel_path == rel.as_str()
{
final_pos = i;
break;
}
}
}
if n > 0 {
sel.set_selected(final_pos);
}
syncing.set(false);
let lv2 = lv.clone();
let rv2 = rv.clone();
gtk4::glib::idle_add_local_once(move || {
let flags = gtk4::ListScrollFlags::FOCUS;
lv2.scroll_to(final_pos, None, flags, None);
rv2.scroll_to(final_pos, None, flags, None);
});
});
}
};
{
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let lh = left_header.clone();
let rh = right_header.clone();
dir_swap_btn.connect_clicked(move |btn| {
let tmp = ld.borrow().clone();
(*ld.borrow_mut()).clone_from(&rd.borrow());
*rd.borrow_mut() = tmp;
lh.set_text(&shortened_path(Path::new(&*ld.borrow())));
lh.set_tooltip_text(Some(&*ld.borrow()));
rh.set_text(&shortened_path(Path::new(&*rd.borrow())));
rh.set_tooltip_text(Some(&*rd.borrow()));
if let Some(win) = btn
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
let ln = Path::new(ld.borrow().as_str())
.file_name()
.map_or_else(|| ld.borrow().clone(), |n| n.to_string_lossy().into_owned());
let rn = Path::new(rd.borrow().as_str())
.file_name()
.map_or_else(|| rd.borrow().clone(), |n| n.to_string_lossy().into_owned());
win.set_title(Some(&format!("{ln} — {rn}")));
}
reload();
});
}
let get_selected_row = {
let tm = tree_model.clone();
let sel = dir_sel.clone();
move || -> Option<String> {
let pos = sel.selected();
let item = tm.item(pos)?;
let row = item.downcast::<TreeListRow>().ok()?;
let obj = row.item().and_downcast::<StringObject>()?;
Some(obj.string().to_string())
}
};
{
let get_row = get_selected_row.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let lv = left_view.clone();
copy_left_btn.connect_clicked(move |_| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
if info.status == FileStatus::RightOnly || info.status == FileStatus::Different {
let rel = info.rel_path;
let src = Path::new(rd.borrow().as_str()).join(&rel);
let dst = Path::new(ld.borrow().as_str()).join(&rel);
let reload = reload.clone();
let lv2 = lv.clone();
let do_copy = move || {
if let Err(e) = copy_path_recursive(&src, &dst)
&& let Some(win) = lv2
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_error_dialog(&win, &format!("Copy failed: {e}"));
}
reload();
};
if info.status == FileStatus::Different {
if let Some(win) = lv
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_confirm_dialog(
&win,
&format!("Overwrite {rel}?"),
"The destination file will be replaced.",
"Overwrite",
do_copy,
);
}
} else {
do_copy();
}
}
}
});
}
{
let get_row = get_selected_row.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let lv = left_view.clone();
copy_right_btn.connect_clicked(move |_| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
if info.status == FileStatus::LeftOnly || info.status == FileStatus::Different {
let rel = info.rel_path;
let src = Path::new(ld.borrow().as_str()).join(&rel);
let dst = Path::new(rd.borrow().as_str()).join(&rel);
let reload = reload.clone();
let lv2 = lv.clone();
let do_copy = move || {
if let Err(e) = copy_path_recursive(&src, &dst)
&& let Some(win) = lv2
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_error_dialog(&win, &format!("Copy failed: {e}"));
}
reload();
};
if info.status == FileStatus::Different {
if let Some(win) = lv
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_confirm_dialog(
&win,
&format!("Overwrite {rel}?"),
"The destination file will be replaced.",
"Overwrite",
do_copy,
);
}
} else {
do_copy();
}
}
}
});
}
{
let get_row = get_selected_row.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let fl = focused_left.clone();
let lv = left_view.clone();
delete_btn.connect_clicked(move |_| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
let rel = info.rel_path;
let status = info.status;
let lp = Path::new(ld.borrow().as_str()).join(&rel);
let rp = Path::new(rd.borrow().as_str()).join(&rel);
let path = match status {
FileStatus::LeftOnly => Some(lp),
FileStatus::RightOnly => Some(rp),
FileStatus::Different | FileStatus::Same => {
Some(if fl.get() { lp } else { rp })
}
};
if let Some(p) = path
&& let Some(win) = lv
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
let reload = reload.clone();
show_confirm_dialog(
&win,
&format!("Move {rel} to trash?"),
"The file will be moved to the system trash.",
"Trash",
move || {
if let Err(e) = gio::File::for_path(&p).trash(gio::Cancellable::NONE) {
eprintln!("Trash failed: {e}");
}
reload();
},
);
}
}
});
}
let dir_action_group = gio::SimpleActionGroup::new();
{
let action = gio::SimpleAction::new("folder-copy-left", None);
let get_row = get_selected_row.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let lv = left_view.clone();
action.connect_activate(move |_, _| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
if info.status == FileStatus::RightOnly || info.status == FileStatus::Different {
let rel = info.rel_path;
let src = Path::new(rd.borrow().as_str()).join(&rel);
let dst = Path::new(ld.borrow().as_str()).join(&rel);
let reload = reload.clone();
let lv2 = lv.clone();
let do_copy = move || {
if let Err(e) = copy_path_recursive(&src, &dst)
&& let Some(win) = lv2
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_error_dialog(&win, &format!("Copy failed: {e}"));
}
reload();
};
if info.status == FileStatus::Different {
if let Some(win) = lv
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_confirm_dialog(
&win,
&format!("Overwrite {rel}?"),
"The destination file will be replaced.",
"Overwrite",
do_copy,
);
}
} else {
do_copy();
}
}
}
});
dir_action_group.add_action(&action);
}
{
let action = gio::SimpleAction::new("folder-copy-right", None);
let get_row = get_selected_row.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let lv = left_view.clone();
action.connect_activate(move |_, _| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
if info.status == FileStatus::LeftOnly || info.status == FileStatus::Different {
let rel = info.rel_path;
let src = Path::new(ld.borrow().as_str()).join(&rel);
let dst = Path::new(rd.borrow().as_str()).join(&rel);
let reload = reload.clone();
let lv2 = lv.clone();
let do_copy = move || {
if let Err(e) = copy_path_recursive(&src, &dst)
&& let Some(win) = lv2
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_error_dialog(&win, &format!("Copy failed: {e}"));
}
reload();
};
if info.status == FileStatus::Different {
if let Some(win) = lv
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
show_confirm_dialog(
&win,
&format!("Overwrite {rel}?"),
"The destination file will be replaced.",
"Overwrite",
do_copy,
);
}
} else {
do_copy();
}
}
}
});
dir_action_group.add_action(&action);
}
{
let action = gio::SimpleAction::new("folder-delete", None);
let get_row = get_selected_row.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let reload = reload_dir.clone();
let fl = focused_left.clone();
let lv = left_view.clone();
action.connect_activate(move |_, _| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
let rel = info.rel_path;
let status = info.status;
let lp = Path::new(ld.borrow().as_str()).join(&rel);
let rp = Path::new(rd.borrow().as_str()).join(&rel);
let path = match status {
FileStatus::LeftOnly => Some(lp),
FileStatus::RightOnly => Some(rp),
FileStatus::Different | FileStatus::Same => {
Some(if fl.get() { lp } else { rp })
}
};
if let Some(p) = path
&& let Some(win) = lv
.root()
.and_then(|r| r.downcast::<ApplicationWindow>().ok())
{
let reload = reload.clone();
show_confirm_dialog(
&win,
&format!("Move {rel} to trash?"),
"The file will be moved to the system trash.",
"Trash",
move || {
if let Err(e) = gio::File::for_path(&p).trash(gio::Cancellable::NONE) {
eprintln!("Trash failed: {e}");
}
reload();
},
);
}
}
});
dir_action_group.add_action(&action);
}
let dir_tab = GtkBox::new(Orientation::Vertical, 0);
dir_tab.append(&dir_toolbar);
dir_tab.append(>k4::Separator::new(Orientation::Horizontal));
dir_tab.append(&dir_paned);
dir_tab.set_vexpand(true);
dir_paned.set_vexpand(true);
dir_tab.insert_action_group("dir", Some(&dir_action_group));
{
let key_ctl = EventControllerKey::new();
key_ctl.set_propagation_phase(gtk4::PropagationPhase::Capture);
let dag = dir_action_group.clone();
key_ctl.connect_key_pressed(move |_, key, _, mods| {
let action_name = if mods.contains(gtk4::gdk::ModifierType::ALT_MASK) {
match key {
k if k == gtk4::gdk::Key::Left => Some("folder-copy-left"),
k if k == gtk4::gdk::Key::Right => Some("folder-copy-right"),
_ => None,
}
} else if mods.is_empty() && key == gtk4::gdk::Key::Delete {
Some("folder-delete")
} else {
None
};
if let Some(name) = action_name {
if let Some(action) = dag.lookup_action(name) {
action
.downcast_ref::<gio::SimpleAction>()
.unwrap()
.activate(None);
}
return gtk4::glib::Propagation::Stop;
}
gtk4::glib::Propagation::Proceed
});
dir_tab.add_controller(key_ctl);
}
let notebook = Notebook::new();
notebook.set_scrollable(true);
notebook.append_page(&dir_tab, Some(&Label::new(Some("Directory"))));
let open_tabs: Rc<RefCell<Vec<FileTab>>> = Rc::new(RefCell::new(Vec::new()));
{
let action = gio::SimpleAction::new("folder-open-diff", None);
let get_row = get_selected_row.clone();
let nb = notebook.clone();
let tabs = open_tabs.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let st = settings.clone();
action.connect_activate(move |_, _| {
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
if !info.is_dir {
open_file_diff(&nb, &info.rel_path, &tabs, &ld.borrow(), &rd.borrow(), &st);
}
}
});
dir_action_group.add_action(&action);
}
{
let dir_menu = gio::Menu::new();
dir_menu.append(Some("Open Diff"), Some("dir.folder-open-diff"));
dir_menu.append(Some("Copy to Left"), Some("dir.folder-copy-left"));
dir_menu.append(Some("Copy to Right"), Some("dir.folder-copy-right"));
dir_menu.append(Some("Delete"), Some("dir.folder-delete"));
let dir_popover_l = PopoverMenu::from_model(Some(&dir_menu));
dir_popover_l.set_parent(&left_view);
dir_popover_l.set_has_arrow(false);
let dir_popover_r = PopoverMenu::from_model(Some(&dir_menu));
dir_popover_r.set_parent(&right_view);
dir_popover_r.set_has_arrow(false);
let act_open = dir_action_group
.lookup_action("folder-open-diff")
.and_downcast::<gio::SimpleAction>()
.unwrap();
let act_left = dir_action_group
.lookup_action("folder-copy-left")
.and_downcast::<gio::SimpleAction>()
.unwrap();
let act_right = dir_action_group
.lookup_action("folder-copy-right")
.and_downcast::<gio::SimpleAction>()
.unwrap();
let setup_dir_ctx = |view: &ColumnView, popover: PopoverMenu| {
let gesture = GestureClick::new();
gesture.set_button(3);
let get_row = get_selected_row.clone();
let ao = act_open.clone();
let al = act_left.clone();
let ar = act_right.clone();
let pop = popover;
let sel = dir_sel.clone();
let tm = tree_model.clone();
let v = view.clone();
gesture.connect_pressed(move |_, _, x, y| {
if let Some(pos) = column_view_row_at_y(&v, x, y, tm.n_items()) {
sel.set_selected(pos);
}
if let Some(raw) = get_row() {
let info = DirRowInfo::decode(&raw);
let is_dir = info.is_dir;
let status = info.status;
ao.set_enabled(
!is_dir && (status == FileStatus::Different || status == FileStatus::Same),
);
al.set_enabled(
status == FileStatus::RightOnly || status == FileStatus::Different,
);
ar.set_enabled(
status == FileStatus::LeftOnly || status == FileStatus::Different,
);
pop.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
pop.popup();
}
});
view.add_controller(gesture);
};
setup_dir_ctx(&left_view, dir_popover_l);
setup_dir_ctx(&right_view, dir_popover_r);
}
let activate_row: Rc<dyn Fn()> = {
let nb = notebook.clone();
let tm = tree_model.clone();
let tabs = open_tabs.clone();
let ld = left_dir.clone();
let rd = right_dir.clone();
let sel = dir_sel.clone();
let st = settings.clone();
Rc::new(move || {
let pos = sel.selected();
if let Some(item) = tm.item(pos) {
let row = item.downcast::<TreeListRow>().unwrap();
let obj = row.item().and_downcast::<StringObject>().unwrap();
let raw = obj.string();
let info = DirRowInfo::decode(&raw);
if info.is_dir {
row.set_expanded(!row.is_expanded());
} else {
open_file_diff(&nb, &info.rel_path, &tabs, &ld.borrow(), &rd.borrow(), &st);
}
}
})
};
{
let ar = activate_row.clone();
left_view.connect_activate(move |_, _| ar());
}
{
let ar = activate_row.clone();
right_view.connect_activate(move |_, _| ar());
}
{
let ar = activate_row.clone();
let kc = EventControllerKey::new();
kc.set_propagation_phase(gtk4::PropagationPhase::Capture);
kc.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Return || key == gtk4::gdk::Key::KP_Enter {
ar();
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
left_view.add_controller(kc);
}
{
let ar = activate_row.clone();
let kc = EventControllerKey::new();
kc.set_propagation_phase(gtk4::PropagationPhase::Capture);
kc.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Return || key == gtk4::gdk::Key::KP_Enter {
ar();
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
right_view.add_controller(kc);
}
let dir_watcher_alive = Rc::new(Cell::new(true));
let (fs_tx, fs_rx) = mpsc::channel::<()>();
let dir_watcher = {
use notify::{RecursiveMode, Watcher};
let ld = left_dir.borrow().clone();
let rd = right_dir.borrow().clone();
let mut w =
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if res.is_ok() {
let _ = fs_tx.send(());
}
})
.expect("Failed to create file watcher");
w.watch(Path::new(&ld), RecursiveMode::Recursive).ok();
w.watch(Path::new(&rd), RecursiveMode::Recursive).ok();
w
};
let tabs_reload = open_tabs.clone();
let ld_reload = left_dir.clone();
let rd_reload = right_dir.clone();
{
let reload = reload_dir.clone();
let alive = dir_watcher_alive.clone();
let st = settings.clone();
let mut dirty = false;
let mut retry_count: u32 = 0;
let mut prev_hide_hidden = st.borrow().hide_hidden_files;
let mut prev_filters = st.borrow().dir_filters.clone();
gtk4::glib::timeout_add_local(Duration::from_millis(500), move || {
let _ = &dir_watcher; if !alive.get() {
return gtk4::glib::ControlFlow::Break;
}
while fs_rx.try_recv().is_ok() {
dirty = true;
retry_count = 0; }
{
let s = st.borrow();
if s.hide_hidden_files != prev_hide_hidden || s.dir_filters != prev_filters {
prev_hide_hidden = s.hide_hidden_files;
prev_filters = s.dir_filters.clone();
dirty = true;
}
}
if dirty
&& !is_saving_under(&[
Path::new(&*ld_reload.borrow()),
Path::new(&*rd_reload.borrow()),
])
{
dirty = false;
reload();
let mut any_tab_failed = false;
for tab in tabs_reload.borrow().iter() {
if !reload_file_tab(tab) {
any_tab_failed = true;
}
}
if any_tab_failed {
retry_count += 1;
if retry_count < 5 {
dirty = true;
} else {
eprintln!(
"Giving up tab reload after {retry_count} retries \
(file unreadable or binary)"
);
}
}
}
gtk4::glib::ControlFlow::Continue
});
}
let left_name = Path::new(left_dir.borrow().as_str())
.file_name()
.map_or_else(
|| left_dir.borrow().clone(),
|n| n.to_string_lossy().into_owned(),
);
let right_name = Path::new(right_dir.borrow().as_str())
.file_name()
.map_or_else(
|| right_dir.borrow().clone(),
|n| n.to_string_lossy().into_owned(),
);
let title = format!("{left_name} — {right_name}");
let window = ApplicationWindow::builder()
.application(app)
.title(&title)
.default_width(900)
.default_height(600)
.child(¬ebook)
.build();
let win_actions = gio::SimpleActionGroup::new();
{
let action = gio::SimpleAction::new("prefs", None);
let w = window.clone();
let st = settings.clone();
action.connect_activate(move |_, _| {
show_preferences(&w, &st);
});
win_actions.add_action(&action);
}
{
let action = gio::SimpleAction::new("close-tab", None);
let nb = notebook.clone();
let w = window.clone();
let tabs = open_tabs.clone();
action.connect_activate(move |_, _| match nb.current_page() {
Some(0) | None => w.close(),
Some(n) => close_notebook_tab(&w, &nb, &tabs, n),
});
win_actions.add_action(&action);
}
window.insert_action_group("win", Some(&win_actions));
{
let tabs = open_tabs.clone();
window.connect_close_request(move |w| handle_notebook_close_request(w, &tabs));
}
if let Some(gtk_app) = window.application() {
gtk_app.set_accels_for_action("diff.prev-chunk", &["<Alt>Up", "<Ctrl>e"]);
gtk_app.set_accels_for_action("diff.next-chunk", &["<Alt>Down", "<Ctrl>d"]);
gtk_app.set_accels_for_action("diff.find", &["<Ctrl>f"]);
gtk_app.set_accels_for_action("diff.find-replace", &["<Ctrl>h"]);
gtk_app.set_accels_for_action("diff.find-next", &["F3"]);
gtk_app.set_accels_for_action("diff.find-prev", &["<Shift>F3"]);
gtk_app.set_accels_for_action("diff.go-to-line", &["<Ctrl>l"]);
gtk_app.set_accels_for_action("diff.export-patch", &["<Ctrl><Shift>p"]);
gtk_app.set_accels_for_action("diff.save", &["<Ctrl>s"]);
gtk_app.set_accels_for_action("win.prefs", &["<Ctrl>comma"]);
gtk_app.set_accels_for_action("win.close-tab", &["<Ctrl>w"]);
}
window.connect_destroy(move |_| {
dir_watcher_alive.set(false);
});
window.present();
left_view.grab_focus();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip() {
let original = DirRowInfo {
status: FileStatus::Different,
name: "test.txt".to_string(),
is_dir: false,
rel_path: "subdir/test.txt".to_string(),
left_size: Some(1024),
left_mtime: None,
right_size: Some(2048),
right_mtime: None,
};
let encoded = original.encode();
let decoded = DirRowInfo::decode(&encoded);
assert_eq!(decoded.status, FileStatus::Different);
assert_eq!(decoded.name, "test.txt");
assert!(!decoded.is_dir);
assert_eq!(decoded.rel_path, "subdir/test.txt");
}
#[test]
fn encode_decode_directory() {
let original = DirRowInfo {
status: FileStatus::Same,
name: "mydir".to_string(),
is_dir: true,
rel_path: "parent/mydir".to_string(),
left_size: None,
left_mtime: None,
right_size: None,
right_mtime: None,
};
let encoded = original.encode();
let decoded = DirRowInfo::decode(&encoded);
assert_eq!(decoded.status, FileStatus::Same);
assert_eq!(decoded.name, "mydir");
assert!(decoded.is_dir);
assert_eq!(decoded.rel_path, "parent/mydir");
}
#[test]
fn encode_decode_left_only() {
let original = DirRowInfo {
status: FileStatus::LeftOnly,
name: "orphan.rs".to_string(),
is_dir: false,
rel_path: "orphan.rs".to_string(),
left_size: Some(512),
left_mtime: None,
right_size: None,
right_mtime: None,
};
let encoded = original.encode();
let decoded = DirRowInfo::decode(&encoded);
assert_eq!(decoded.status, FileStatus::LeftOnly);
assert_eq!(decoded.name, "orphan.rs");
}
#[test]
fn encode_decode_right_only() {
let original = DirRowInfo {
status: FileStatus::RightOnly,
name: "new_file.rs".to_string(),
is_dir: false,
rel_path: "new_file.rs".to_string(),
left_size: None,
left_mtime: None,
right_size: Some(256),
right_mtime: None,
};
let encoded = original.encode();
let decoded = DirRowInfo::decode(&encoded);
assert_eq!(decoded.status, FileStatus::RightOnly);
assert_eq!(decoded.name, "new_file.rs");
}
#[test]
fn decode_field_out_of_bounds() {
assert_eq!(DirRowInfo::get_field_text("a\x1fb", 5), "");
assert_eq!(DirRowInfo::get_field_text("", 0), "");
}
#[test]
fn decode_name_with_special_chars() {
let original = DirRowInfo {
status: FileStatus::Same,
name: "file with spaces.txt".to_string(),
is_dir: false,
rel_path: "path/file with spaces.txt".to_string(),
left_size: None,
left_mtime: None,
right_size: None,
right_mtime: None,
};
let encoded = original.encode();
let decoded = DirRowInfo::decode(&encoded);
assert_eq!(decoded.name, "file with spaces.txt");
assert_eq!(decoded.rel_path, "path/file with spaces.txt");
}
#[test]
fn file_status_codes() {
assert_eq!(FileStatus::Same.code(), "S");
assert_eq!(FileStatus::Different.code(), "D");
assert_eq!(FileStatus::LeftOnly.code(), "L");
assert_eq!(FileStatus::RightOnly.code(), "R");
}
}