mergers 0.5.0

A visual diff and merge tool for files and directories
Documentation
#![allow(
    clippy::cast_sign_loss,
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_lossless,
    clippy::cast_precision_loss,
    clippy::needless_pass_by_value,
    clippy::similar_names,
    clippy::too_many_lines,
    clippy::wildcard_imports
)]

use std::{
    cell::{Cell, RefCell},
    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
    fmt::Write as _,
    fs,
    path::{Path, PathBuf},
    rc::Rc,
    sync::mpsc,
    time::{Duration, SystemTime},
};

use chrono::{DateTime, Local};
use gtk4::{
    Adjustment, Application, ApplicationWindow, Box as GtkBox, Button, ColumnView,
    ColumnViewColumn, CssProvider, DrawingArea, Entry, EventControllerFocus, EventControllerKey,
    GestureClick, Image, Label, ListItem, Notebook, Orientation, Paned, PolicyType, PopoverMenu,
    Revealer, ScrolledWindow, SignalListItemFactory, SingleSelection, StringObject, TextBuffer,
    TextSearchFlags, TextTag, TextView, ToggleButton, TreeExpander, TreeListModel, TreeListRow,
    gdk::Display, gio, gio::ListStore, prelude::*,
};
use sourceview5::prelude::*;

use crate::{
    CompareMode,
    myers::{self, DiffChunk, DiffTag},
    settings::Settings,
};

mod common;
mod diff_view;
mod dir_window;
mod file_window;
mod merge_view;
mod preferences;
mod vcs_window;
mod welcome;

use common::*;
use diff_view::*;
use dir_window::*;
use file_window::*;
use merge_view::*;
use preferences::*;
use vcs_window::*;
use welcome::*;

const CSS: &str = r"
.diff-changed { color: #729fcf; font-weight: bold; }
.diff-deleted { color: #f57900; }
.diff-inserted { color: #73d216; }
.diff-missing { color: #888a85; font-style: italic; }
.info-bar { background: #3584e4; padding: 8px 12px; }
.info-bar label { color: white; }
.chunk-label { font-size: 0.9em; }
.linked > button { min-width: 0; padding: 4px 8px; }
.find-bar { background: alpha(@theme_bg_color, 0.95); border-top: 1px solid @borders; padding: 4px 6px; }
.find-bar entry { min-height: 28px; }
.goto-entry { min-height: 28px; }
.dir-pane-focused { border: 2px solid @accent_color; border-radius: 4px; }
.dir-pane-inactive { border: 2px solid alpha(@borders, 0.5); border-radius: 4px; }
";

const SEP: char = '\x1f';

thread_local! {
    static FONT_PROVIDER: CssProvider = CssProvider::new();
    static FONT_REGISTERED: Cell<bool> = const { Cell::new(false) };
    static SAVING_PATHS: RefCell<HashSet<PathBuf>> = RefCell::new(HashSet::new());
}

fn update_font_css(settings: &Settings) {
    let font_desc = gtk4::pango::FontDescription::from_string(&settings.font);
    let css = format!(
        ".meld-editor {{ font-family: \"{}\"; font-size: {}pt; }}",
        font_desc.family().unwrap_or("Monospace".into()),
        font_desc.size() / gtk4::pango::SCALE,
    );
    FONT_PROVIDER.with(|provider| {
        provider.load_from_string(&css);
        FONT_REGISTERED.with(|reg| {
            if !reg.get() {
                gtk4::style_context_add_provider_for_display(
                    &Display::default().expect("GTK display must be available"),
                    provider,
                    gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
                );
                reg.set(true);
            }
        });
    });
}

/// Detect whether the system prefers dark mode.
///
/// Checks GTK settings first (works on GNOME/freedesktop).  On macOS, falls
/// back to querying `defaults read -g AppleInterfaceStyle`.
fn detect_dark_mode(gtk_settings: &gtk4::Settings) -> bool {
    if gtk_settings.is_gtk_application_prefer_dark_theme() {
        return true;
    }
    if gtk_settings
        .gtk_theme_name()
        .is_some_and(|n| n.to_lowercase().contains("dark"))
    {
        return true;
    }
    // macOS: GTK's quartz backend may not reflect the system appearance,
    // so check directly.
    #[cfg(target_os = "macos")]
    {
        if let Ok(output) = std::process::Command::new("defaults")
            .args(["read", "-g", "AppleInterfaceStyle"])
            .output()
            && output.status.success()
        {
            let s = String::from_utf8_lossy(&output.stdout);
            return s.trim().eq_ignore_ascii_case("dark");
        }
    }
    false
}

// ─── Main UI ───────────────────────────────────────────────────────────────

/// Initialize the application UI based on the comparison mode.
///
/// # Panics
///
/// Panics if the default GTK display cannot be obtained during startup.
pub fn build_ui(application: &Application, mode: CompareMode) {
    // Shared settings for the whole application instance
    let settings = Rc::new(RefCell::new(Settings::load()));

    // Ctrl+Q: quit application
    {
        let quit = gio::SimpleAction::new("quit", None);
        let app = application.clone();
        quit.connect_activate(move |_, _| {
            for w in app.windows() {
                w.close();
            }
        });
        application.add_action(&quit);
        application.set_accels_for_action("app.quit", &["<Ctrl>q"]);
    }

    {
        let settings = settings.clone();
        application.connect_startup(move |_| {
            // Load application CSS
            let provider = CssProvider::new();
            provider.load_from_string(CSS);
            gtk4::style_context_add_provider_for_display(
                &Display::default().expect("GTK display must be available"),
                &provider,
                gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
            );

            // Auto-switch to dark mode when the system prefers it.
            if let Some(gtk_settings) = gtk4::Settings::default() {
                let is_dark = detect_dark_mode(&gtk_settings);
                gtk_settings.set_gtk_application_prefer_dark_theme(is_dark);
            }

            // Initial font CSS update
            update_font_css(&settings.borrow());
        });
    }

    application.connect_activate(move |app| {
        let mode = mode.clone();
        let settings = settings.clone();

        match mode {
            CompareMode::Dirs {
                left,
                right,
                labels: _,
            } => build_dir_window(app, left, right, settings),
            CompareMode::Files {
                left,
                right,
                labels,
            } => build_file_window(app, left, right, &labels, &settings),
            CompareMode::Merge {
                left,
                middle,
                right,
                labels,
            } => build_merge_window(app, left, middle, right, &labels, &settings),
            CompareMode::Vcs { dir } => build_vcs_window(app, dir, settings),
            CompareMode::Welcome => build_welcome_window(app, settings),
        }
    });
}