use crate::color::{self, AnimationGradient};
use ratatui::style::Color;
use serde::{de, Deserialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct DarkLight {
pub dark: String,
#[serde(default)]
#[allow(dead_code)] pub light: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ThemeTokens {
pub text: Option<DarkLight>,
pub text_muted: Option<DarkLight>,
pub primary: Option<DarkLight>,
pub secondary: Option<DarkLight>,
pub accent: Option<DarkLight>,
pub error: Option<DarkLight>,
pub warning: Option<DarkLight>,
pub success: Option<DarkLight>,
pub info: Option<DarkLight>,
pub background: Option<DarkLight>,
pub background_panel: Option<DarkLight>,
pub background_element: Option<DarkLight>,
pub border: Option<DarkLight>,
pub border_active: Option<DarkLight>,
pub border_subtle: Option<DarkLight>,
pub diff_added: Option<DarkLight>,
pub diff_added_bg: Option<DarkLight>,
pub diff_removed: Option<DarkLight>,
pub diff_removed_bg: Option<DarkLight>,
pub diff_context: Option<DarkLight>,
pub diff_line_number: Option<DarkLight>,
pub diff_ext_marker: Option<DarkLight>,
pub diff_modified_bg: Option<DarkLight>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
pub name: Option<String>,
pub mode: Option<String>,
pub defs: HashMap<String, String>,
pub theme: ThemeTokens,
}
const BUILTIN_THEMES: &[(&str, &str)] = &[
(
"aura",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/aura.json")),
),
(
"ayu",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/ayu.json")),
),
(
"catppuccin",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/catppuccin.json"
)),
),
(
"catppuccin-frappe",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/catppuccin-frappe.json"
)),
),
(
"catppuccin-macchiato",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/catppuccin-macchiato.json"
)),
),
(
"cobalt2",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/cobalt2.json")),
),
(
"dracula",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/dracula.json")),
),
(
"everforest",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/everforest.json"
)),
),
(
"flexoki",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/flexoki.json")),
),
(
"github",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/github.json")),
),
(
"gruvbox",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/gruvbox.json")),
),
(
"kanagawa",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/kanagawa.json")),
),
(
"material",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/material.json")),
),
(
"monokai",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/monokai.json")),
),
(
"nightowl",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/nightowl.json")),
),
(
"nord",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/nord.json")),
),
(
"one-dark",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/one-dark.json")),
),
(
"palenight",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/palenight.json"
)),
),
(
"rosepine",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/rosepine.json")),
),
(
"solarized",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/solarized.json"
)),
),
(
"synthwave84",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/synthwave84.json"
)),
),
(
"tokyonight",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/tokyonight.json"
)),
),
(
"zenburn",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/themes/zenburn.json")),
),
];
pub fn builtin_theme_names() -> Vec<&'static str> {
BUILTIN_THEMES.iter().map(|(name, _)| *name).collect()
}
impl ThemeConfig {
pub fn is_light_mode(&self) -> bool {
self.mode
.as_ref()
.map(|m| m.eq_ignore_ascii_case("light"))
.unwrap_or(false)
}
fn resolved_config(&self, light_mode: bool) -> ThemeConfig {
let mut base = self
.name
.as_deref()
.and_then(|name| ThemeConfig::load_named(name, light_mode))
.unwrap_or_default();
base.name = self.name.clone();
if self.mode.is_some() {
base.mode = self.mode.clone();
}
base.defs.extend(self.defs.clone());
merge_theme_tokens(&mut base.theme, &self.theme);
base
}
fn builtin(name: &str) -> Option<ThemeConfig> {
let key = name.to_ascii_lowercase();
let json = BUILTIN_THEMES
.iter()
.find(|(theme_name, _)| *theme_name == key)
.map(|(_, json)| *json)?;
let mut config: ThemeConfig =
serde_json::from_str(json).expect("builtin theme JSON should parse");
config.name = Some(key);
Some(config)
}
fn load_named(name: &str, light_mode: bool) -> Option<ThemeConfig> {
ThemeConfig::custom(name, light_mode).or_else(|| ThemeConfig::builtin(name))
}
fn custom(name: &str, light_mode: bool) -> Option<ThemeConfig> {
let path = resolve_theme_json_path(name, light_mode)?;
let content = fs::read_to_string(&path).ok()?;
let mut config: ThemeConfig = serde_json::from_str(&content).ok()?;
config.name = Some(normalize_custom_theme_name(name));
Some(config)
}
}
#[derive(Debug, Clone)]
pub struct ResolvedTheme {
pub text: Color,
pub text_muted: Color,
pub primary: Color,
pub accent: Color,
pub error: Color,
pub warning: Color,
pub success: Color,
pub info: Color,
pub background: Option<Color>,
pub background_panel: Option<Color>,
pub background_element: Option<Color>,
#[allow(dead_code)]
pub border: Color,
pub border_active: Color,
pub border_subtle: Color,
pub diff_context: Color,
pub diff_line_number: Color,
pub diff_ext_marker: Color,
pub diff_added_bg: Option<Color>,
pub diff_removed_bg: Option<Color>,
pub diff_modified_bg: Option<Color>,
pub insert: AnimationGradient,
pub delete: AnimationGradient,
pub modify: AnimationGradient,
}
impl ResolvedTheme {
pub fn insert_dim(&self) -> Color {
color::dim_color_from_gradient(&self.insert)
}
pub fn insert_base(&self) -> Color {
let rgb = color::hsl_to_rgb(self.insert.base);
contrast_safe_diff_foreground(
Color::Rgb(rgb.r, rgb.g, rgb.b),
self.diff_added_bg,
self.diff_context,
self.background
.and_then(color::relative_luminance)
.is_some_and(|luminance| luminance > 0.5),
)
}
pub fn delete_dim(&self) -> Color {
color::dim_color_from_gradient(&self.delete)
}
pub fn delete_base(&self) -> Color {
let rgb = color::hsl_to_rgb(self.delete.base);
contrast_safe_diff_foreground(
Color::Rgb(rgb.r, rgb.g, rgb.b),
self.diff_removed_bg,
self.diff_context,
self.background
.and_then(color::relative_luminance)
.is_some_and(|luminance| luminance > 0.5),
)
}
pub fn modify_dim(&self) -> Color {
color::dim_color_from_gradient(&self.modify)
}
pub fn modify_base(&self) -> Color {
let rgb = color::hsl_to_rgb(self.modify.base);
Color::Rgb(rgb.r, rgb.g, rgb.b)
}
pub fn warning_dim(&self) -> Color {
color::dim_color(self.warning)
}
}
fn contrast_safe_diff_foreground(
base: Color,
background: Option<Color>,
fallback: Color,
light_mode: bool,
) -> Color {
if !light_mode {
return base;
}
let Some(background) = background else {
return base;
};
if color::contrast_ratio(base, background).unwrap_or(4.5) >= 4.5 {
return base;
}
if color::contrast_ratio(fallback, background).unwrap_or(0.0) >= 4.5 {
return fallback;
}
let black = Color::Rgb(0, 0, 0);
let white = Color::Rgb(255, 255, 255);
if color::contrast_ratio(black, background).unwrap_or(0.0)
>= color::contrast_ratio(white, background).unwrap_or(0.0)
{
black
} else {
white
}
}
impl Default for ResolvedTheme {
fn default() -> Self {
ThemeConfig::default().resolve(false)
}
}
impl ThemeConfig {
pub fn resolve(&self, light_mode: bool) -> ResolvedTheme {
let merged = self.resolved_config(light_mode);
let defs = &merged.defs;
let tokens = &merged.theme;
let resolve = |token: &Option<DarkLight>, fallback: Color| -> Color {
token
.as_ref()
.and_then(|dl| {
if light_mode {
dl.light
.as_ref()
.and_then(|v| color::resolve_color(v, defs))
.or_else(|| color::resolve_color(&dl.dark, defs))
} else {
color::resolve_color(&dl.dark, defs)
}
})
.unwrap_or(fallback)
};
let resolve_bg = |token: &Option<DarkLight>| -> Option<Color> {
token.as_ref().and_then(|dl| {
let value_str = if light_mode {
dl.light.as_ref().unwrap_or(&dl.dark)
} else {
&dl.dark
};
let value = value_str.trim().to_lowercase();
if value == "transparent" || value == "none" {
None
} else {
color::resolve_color(value_str, defs)
}
})
};
let diff_added = resolve(&tokens.diff_added, Color::Green);
let diff_removed = resolve(&tokens.diff_removed, Color::Red);
let warning = resolve(&tokens.warning, Color::Yellow);
let background = resolve_bg(&tokens.background);
let background_panel = resolve_bg(&tokens.background_panel);
let background_element = resolve_bg(&tokens.background_element);
let base_bg = background.or(background_panel).or(background_element);
let diff_added_bg = resolve_bg(&tokens.diff_added_bg)
.or_else(|| base_bg.and_then(|bg| color::blend_colors(bg, diff_added, 0.18)));
let diff_removed_bg = resolve_bg(&tokens.diff_removed_bg)
.or_else(|| base_bg.and_then(|bg| color::blend_colors(bg, diff_removed, 0.18)));
let diff_modified_bg = resolve_bg(&tokens.diff_modified_bg)
.or_else(|| base_bg.and_then(|bg| color::blend_colors(bg, warning, 0.16)));
ResolvedTheme {
text: resolve(&tokens.text, Color::Reset),
text_muted: resolve(&tokens.text_muted, Color::DarkGray),
primary: resolve(&tokens.primary, Color::Cyan),
accent: resolve(&tokens.accent, Color::Cyan),
error: resolve(&tokens.error, Color::Red),
warning,
success: resolve(&tokens.success, Color::Green),
info: resolve(&tokens.info, Color::Blue),
background,
background_panel,
background_element,
border: resolve(&tokens.border, Color::DarkGray),
border_active: resolve(&tokens.border_active, Color::Gray),
border_subtle: resolve(&tokens.border_subtle, Color::DarkGray),
diff_context: resolve(&tokens.diff_context, Color::Reset),
diff_line_number: resolve(&tokens.diff_line_number, Color::DarkGray),
diff_ext_marker: resolve(&tokens.diff_ext_marker, Color::DarkGray),
diff_added_bg,
diff_removed_bg,
diff_modified_bg,
insert: color::gradient_from_color(diff_added),
delete: color::gradient_from_color(diff_removed),
modify: color::gradient_from_color(warning),
}
}
}
fn normalize_custom_theme_name(name: &str) -> String {
let path = Path::new(name);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
stem.trim_end_matches("-dark")
.trim_end_matches("-light")
.to_string()
}
fn theme_search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(config_path) = Config::config_path() {
if let Some(parent) = config_path.parent() {
let base = parent.to_path_buf();
dirs.push(base.clone());
dirs.push(base.join("themes"));
}
}
if let Some(config_dir) = dirs::config_dir() {
let base = config_dir.join("oyo");
if !dirs.contains(&base) {
dirs.push(base.clone());
}
let themes = base.join("themes");
if !dirs.contains(&themes) {
dirs.push(themes);
}
}
dirs
}
fn find_theme_candidate(dir: &Path, candidate: &str) -> Option<PathBuf> {
let path = dir.join(candidate);
if path.exists() {
return Some(path);
}
fs::read_dir(dir)
.ok()?
.flatten()
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(candidate)
})
.map(|entry| entry.path())
}
fn resolve_theme_json_path(name: &str, light_mode: bool) -> Option<PathBuf> {
let path = Path::new(name);
let has_ext = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("json"));
if path.is_absolute() || name.contains(std::path::MAIN_SEPARATOR) {
if path.exists() {
return Some(path.to_path_buf());
}
if !has_ext {
let candidate = path.with_extension("json");
if candidate.exists() {
return Some(candidate);
}
}
return None;
}
let stem = if has_ext {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(name)
.to_string()
} else {
name.to_string()
};
let has_variant = stem.ends_with("-light") || stem.ends_with("-dark");
let mut candidates = Vec::new();
if !has_variant {
if light_mode {
candidates.push(format!("{stem}-light.json"));
} else {
candidates.push(format!("{stem}-dark.json"));
}
candidates.push(format!("{stem}.json"));
if light_mode {
candidates.push(format!("{stem}-dark.json"));
} else {
candidates.push(format!("{stem}-light.json"));
}
} else if has_ext {
candidates.push(name.to_string());
} else {
candidates.push(format!("{stem}.json"));
}
for dir in theme_search_dirs() {
for candidate in &candidates {
if let Some(path) = find_theme_candidate(&dir, candidate) {
return Some(path);
}
}
}
None
}
pub fn list_ui_themes() -> Vec<String> {
let mut names: BTreeSet<String> = BTreeSet::new();
for name in builtin_theme_names() {
names.insert(name.to_string());
}
for dir in theme_search_dirs() {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
{
if let Some(stem) = path.file_stem().and_then(|n| n.to_str()) {
let base = stem
.trim_end_matches("-dark")
.trim_end_matches("-light")
.to_ascii_lowercase();
names.insert(base);
}
}
}
}
}
names.into_iter().collect()
}
fn merge_theme_tokens(base: &mut ThemeTokens, overlay: &ThemeTokens) {
if overlay.text.is_some() {
base.text = overlay.text.clone();
}
if overlay.text_muted.is_some() {
base.text_muted = overlay.text_muted.clone();
}
if overlay.primary.is_some() {
base.primary = overlay.primary.clone();
}
if overlay.secondary.is_some() {
base.secondary = overlay.secondary.clone();
}
if overlay.accent.is_some() {
base.accent = overlay.accent.clone();
}
if overlay.error.is_some() {
base.error = overlay.error.clone();
}
if overlay.warning.is_some() {
base.warning = overlay.warning.clone();
}
if overlay.success.is_some() {
base.success = overlay.success.clone();
}
if overlay.info.is_some() {
base.info = overlay.info.clone();
}
if overlay.background.is_some() {
base.background = overlay.background.clone();
}
if overlay.background_panel.is_some() {
base.background_panel = overlay.background_panel.clone();
}
if overlay.background_element.is_some() {
base.background_element = overlay.background_element.clone();
}
if overlay.border.is_some() {
base.border = overlay.border.clone();
}
if overlay.border_active.is_some() {
base.border_active = overlay.border_active.clone();
}
if overlay.border_subtle.is_some() {
base.border_subtle = overlay.border_subtle.clone();
}
if overlay.diff_added.is_some() {
base.diff_added = overlay.diff_added.clone();
}
if overlay.diff_added_bg.is_some() {
base.diff_added_bg = overlay.diff_added_bg.clone();
}
if overlay.diff_removed.is_some() {
base.diff_removed = overlay.diff_removed.clone();
}
if overlay.diff_removed_bg.is_some() {
base.diff_removed_bg = overlay.diff_removed_bg.clone();
}
if overlay.diff_context.is_some() {
base.diff_context = overlay.diff_context.clone();
}
if overlay.diff_line_number.is_some() {
base.diff_line_number = overlay.diff_line_number.clone();
}
if overlay.diff_ext_marker.is_some() {
base.diff_ext_marker = overlay.diff_ext_marker.clone();
}
if overlay.diff_modified_bg.is_some() {
base.diff_modified_bg = overlay.diff_modified_bg.clone();
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub zen: bool,
pub topbar: bool,
pub auto_center: bool,
pub watch: bool,
pub overscroll: bool,
pub confirm_quit: bool,
pub view_mode: Option<String>,
pub line_wrap: bool,
pub fold_context: FoldContextMode,
pub fold_context_lines: usize,
pub scrollbar: bool,
pub strikethrough_deletions: bool,
pub gutter_signs: bool,
pub syntax: SyntaxConfig,
pub unified: UnifiedViewConfig,
pub split: SplitViewConfig,
pub evo: EvoViewConfig,
pub diff: DiffConfig,
pub blame: BlameConfig,
pub time: TimeConfig,
pub toasts: ToastConfig,
pub stepping: bool,
pub primary_marker: String,
pub primary_marker_right: Option<String>,
pub extent_marker: String,
pub extent_marker_left: Option<String>,
pub extent_marker_right: Option<String>,
pub extent_marker_deleted: String,
pub theme: ThemeConfig,
}
impl UiConfig {
pub fn extent_marker_left(&self) -> &str {
self.extent_marker_left
.as_deref()
.unwrap_or(&self.extent_marker)
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
zen: false,
topbar: true,
auto_center: true,
watch: true,
overscroll: false,
confirm_quit: true,
view_mode: None,
line_wrap: false,
fold_context: FoldContextMode::Expandable,
fold_context_lines: 3,
scrollbar: true,
strikethrough_deletions: false,
gutter_signs: true,
syntax: SyntaxConfig::default(),
unified: UnifiedViewConfig::default(),
split: SplitViewConfig::default(),
evo: EvoViewConfig::default(),
diff: DiffConfig::default(),
blame: BlameConfig::default(),
time: TimeConfig::default(),
toasts: ToastConfig::default(),
stepping: false,
primary_marker: "▶".to_string(),
primary_marker_right: None,
extent_marker: "┃".to_string(),
extent_marker_left: None,
extent_marker_right: None,
extent_marker_deleted: "╏".to_string(),
theme: ThemeConfig::default(),
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
pub struct ToastConfig {
pub enabled: bool,
pub position: ToastPositionConfig,
}
impl Default for ToastConfig {
fn default() -> Self {
Self {
enabled: true,
position: ToastPositionConfig::BottomRight,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToastPositionConfig {
TopLeft,
TopRight,
BottomLeft,
#[default]
BottomRight,
Center,
}
impl ToastPositionConfig {
pub fn toast_position(self) -> ratatui_comfy_toaster::ToastPosition {
match self {
Self::TopLeft => ratatui_comfy_toaster::ToastPosition::TopLeft,
Self::TopRight => ratatui_comfy_toaster::ToastPosition::TopRight,
Self::BottomLeft => ratatui_comfy_toaster::ToastPosition::BottomLeft,
Self::BottomRight => ratatui_comfy_toaster::ToastPosition::BottomRight,
Self::Center => ratatui_comfy_toaster::ToastPosition::Center,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StepWrapMode {
#[default]
None,
Step,
File,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum HunkWrapMode {
#[default]
None,
Hunk,
File,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct WrapConfig {
pub step: StepWrapMode,
pub hunk: HunkWrapMode,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct NavigationConfig {
pub wrap: WrapConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct SplitViewConfig {
pub align_lines: bool,
pub align_fill: String,
}
impl Default for SplitViewConfig {
fn default() -> Self {
Self {
align_lines: true,
align_fill: "╱".to_string(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct UnifiedViewConfig {
pub modified_step_mode: ModifiedStepMode,
}
impl Default for UnifiedViewConfig {
fn default() -> Self {
Self {
modified_step_mode: ModifiedStepMode::Mixed,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct EvoViewConfig {
pub syntax: EvoSyntaxMode,
}
impl Default for EvoViewConfig {
fn default() -> Self {
Self {
syntax: EvoSyntaxMode::Context,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct DiffConfig {
#[serde(default = "diff_bg_default")]
pub bg: bool,
#[serde(default = "diff_fg_default")]
pub fg: DiffForegroundMode,
#[serde(default = "diff_highlight_default")]
pub highlight: DiffHighlightMode,
#[serde(default = "diff_max_bytes_default")]
pub max_bytes: u64,
#[serde(default = "diff_full_context_max_bytes_default")]
pub full_context_max_bytes: u64,
#[serde(default = "diff_defer_default")]
pub defer: bool,
#[serde(default = "diff_idle_ms_default")]
pub idle_ms: u64,
#[serde(default = "diff_extent_marker_default")]
pub extent_marker: DiffExtentMarkerMode,
#[serde(default = "diff_extent_marker_scope_default")]
pub extent_marker_scope: DiffExtentMarkerScope,
#[serde(default = "diff_extent_marker_context_default")]
pub extent_marker_context: bool,
#[serde(default = "diff_preview_change_bars_default")]
pub preview_change_bars: bool,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
bg: diff_bg_default(),
fg: diff_fg_default(),
highlight: diff_highlight_default(),
max_bytes: diff_max_bytes_default(),
full_context_max_bytes: diff_full_context_max_bytes_default(),
defer: diff_defer_default(),
idle_ms: diff_idle_ms_default(),
extent_marker: diff_extent_marker_default(),
extent_marker_scope: diff_extent_marker_scope_default(),
extent_marker_context: diff_extent_marker_context_default(),
preview_change_bars: diff_preview_change_bars_default(),
}
}
}
fn diff_bg_default() -> bool {
true
}
fn diff_fg_default() -> DiffForegroundMode {
DiffForegroundMode::Syntax
}
fn diff_highlight_default() -> DiffHighlightMode {
DiffHighlightMode::Word
}
fn diff_max_bytes_default() -> u64 {
16 * 1024 * 1024
}
fn diff_full_context_max_bytes_default() -> u64 {
2 * 1024 * 1024
}
fn diff_defer_default() -> bool {
true
}
fn diff_idle_ms_default() -> u64 {
250
}
fn diff_extent_marker_default() -> DiffExtentMarkerMode {
DiffExtentMarkerMode::Diff
}
fn diff_extent_marker_scope_default() -> DiffExtentMarkerScope {
DiffExtentMarkerScope::Hunk
}
fn diff_extent_marker_context_default() -> bool {
false
}
fn diff_preview_change_bars_default() -> bool {
true
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum FoldContextMode {
Off,
#[default]
#[serde(alias = "on")]
Expandable,
}
impl FoldContextMode {
pub fn is_enabled(self) -> bool {
matches!(self, Self::Expandable)
}
pub fn next(self) -> Self {
match self {
Self::Off => Self::Expandable,
Self::Expandable => Self::Off,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvoSyntaxMode {
#[default]
Context,
Full,
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModifiedStepMode {
#[default]
Mixed,
Modified,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DiffForegroundMode {
#[default]
Theme,
Syntax,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DiffHighlightMode {
#[default]
Text,
Word,
None,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DiffExtentMarkerMode {
#[default]
Neutral,
Diff,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DiffExtentMarkerScope {
#[default]
Progress,
Hunk,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum BlameMode {
#[default]
OneShot,
Toggle,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct BlameConfig {
pub enabled: bool,
pub mode: BlameMode,
pub hunk_hint: bool,
}
impl Default for BlameConfig {
fn default() -> Self {
Self {
enabled: false,
mode: BlameMode::OneShot,
hunk_hint: true,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TimeMode {
#[default]
Relative,
Absolute,
Custom,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(from = "TimeConfigDef")]
pub struct TimeConfig {
pub mode: TimeMode,
pub format: String,
}
impl Default for TimeConfig {
fn default() -> Self {
Self {
mode: TimeMode::Relative,
format: String::new(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TimeConfigDef {
Mode(TimeMode),
Detailed {
#[serde(default)]
mode: TimeMode,
format: Option<String>,
},
}
impl From<TimeConfigDef> for TimeConfig {
fn from(def: TimeConfigDef) -> Self {
match def {
TimeConfigDef::Mode(mode) => Self {
mode,
..Self::default()
},
TimeConfigDef::Detailed { mode, format } => Self {
mode,
format: format.unwrap_or_default(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SyntaxMode {
#[default]
On,
Off,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SyntaxConfig {
pub mode: SyntaxMode,
pub theme: String,
pub warmup: SyntaxWarmupConfig,
}
impl Default for SyntaxConfig {
fn default() -> Self {
Self {
mode: SyntaxMode::On,
theme: String::new(),
warmup: SyntaxWarmupConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SyntaxWarmupConfig {
#[serde(default = "syntax_warmup_active_lines_default")]
pub active_lines: usize,
#[serde(default = "syntax_warmup_pending_lines_default")]
pub pending_lines: usize,
#[serde(default = "syntax_warmup_idle_lines_default")]
pub idle_lines: usize,
#[serde(default = "syntax_warmup_debounce_ms_default")]
pub debounce_ms: u64,
}
impl Default for SyntaxWarmupConfig {
fn default() -> Self {
Self {
active_lines: syntax_warmup_active_lines_default(),
pending_lines: syntax_warmup_pending_lines_default(),
idle_lines: syntax_warmup_idle_lines_default(),
debounce_ms: syntax_warmup_debounce_ms_default(),
}
}
}
fn syntax_warmup_active_lines_default() -> usize {
100
}
fn syntax_warmup_pending_lines_default() -> usize {
300
}
fn syntax_warmup_idle_lines_default() -> usize {
1_000
}
fn syntax_warmup_debounce_ms_default() -> u64 {
80
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct PlaybackConfig {
pub speed: u64,
pub autoplay: bool,
pub animation: bool,
pub animation_duration: u64,
pub auto_step_on_enter: bool,
pub auto_step_blank_files: bool,
}
impl Default for PlaybackConfig {
fn default() -> Self {
Self {
speed: 200,
autoplay: false,
animation: true,
animation_duration: 120,
auto_step_on_enter: true,
auto_step_blank_files: true,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FilePanelPosition {
#[default]
Left,
Right,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct FilesConfig {
pub panel_visible: bool,
pub panel_width: u16,
pub panel_position: FilePanelPosition,
pub counts: FileCountMode,
pub scan: FileScanConfig,
}
impl Default for FilesConfig {
fn default() -> Self {
Self {
panel_visible: true,
panel_width: 30,
panel_position: FilePanelPosition::Left,
counts: FileCountMode::Active,
scan: FileScanConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum GitIgnoreMode {
#[default]
Auto,
On,
Off,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum GitIgnoreModeDef {
Bool(bool),
String(String),
}
impl<'de> Deserialize<'de> for GitIgnoreMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
match GitIgnoreModeDef::deserialize(deserializer)? {
GitIgnoreModeDef::Bool(true) => Ok(Self::On),
GitIgnoreModeDef::Bool(false) => Ok(Self::Off),
GitIgnoreModeDef::String(value) => match value.as_str() {
"auto" => Ok(Self::Auto),
"true" | "on" => Ok(Self::On),
"false" | "off" => Ok(Self::Off),
other => Err(de::Error::unknown_variant(
other,
&["auto", "true", "false", "on", "off"],
)),
},
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct FileScanConfig {
pub git_ignore: GitIgnoreMode,
pub ignore_globs: Vec<String>,
}
impl Default for FileScanConfig {
fn default() -> Self {
Self {
git_ignore: GitIgnoreMode::Auto,
ignore_globs: oyo_core::multi::DEFAULT_SCAN_IGNORE_GLOBS
.iter()
.map(|pattern| (*pattern).to_string())
.collect(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct NoStepConfig {
pub auto_jump_on_enter: bool,
}
impl Default for NoStepConfig {
fn default() -> Self {
Self {
auto_jump_on_enter: true,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub open_at_line: bool,
pub wait: bool,
}
impl Default for EditorConfig {
fn default() -> Self {
Self {
command: None,
args: None,
open_at_line: true,
wait: true,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileCountMode {
#[default]
Active,
Focused,
All,
Off,
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MentionFileScope {
Changed,
#[default]
Repo,
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MentionFinder {
#[default]
Auto,
Builtin,
Fzf,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct MentionConfig {
pub file_scope: MentionFileScope,
pub finder: MentionFinder,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct CommentsConfig {
pub mentions: MentionConfig,
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReviewHookEvent {
CommentSaved,
CommentDeleted,
CommentsCleared,
#[default]
ReviewReady,
}
impl ReviewHookEvent {
pub fn as_str(self) -> &'static str {
match self {
Self::CommentSaved => "comment_saved",
Self::CommentDeleted => "comment_deleted",
Self::CommentsCleared => "comments_cleared",
Self::ReviewReady => "review_ready",
}
}
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReviewHookStdin {
#[default]
Json,
None,
}
fn default_hook_stdin() -> ReviewHookStdin {
ReviewHookStdin::Json
}
fn default_hook_blocking() -> bool {
true
}
fn default_hook_timeout_ms() -> u64 {
30_000
}
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
pub struct ReviewHookConfig {
pub id: String,
pub on: ReviewHookEvent,
pub command: String,
pub args: Vec<String>,
#[serde(default = "default_hook_stdin")]
pub stdin: ReviewHookStdin,
#[serde(default = "default_hook_blocking")]
pub blocking: bool,
#[serde(default = "default_hook_timeout_ms")]
pub timeout_ms: u64,
}
impl Default for ReviewHookConfig {
fn default() -> Self {
Self {
id: String::new(),
on: ReviewHookEvent::ReviewReady,
command: String::new(),
args: Vec::new(),
stdin: ReviewHookStdin::Json,
blocking: true,
timeout_ms: default_hook_timeout_ms(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
pub struct ReviewActionConfig {
pub id: String,
pub label: String,
pub key: Option<String>,
pub on: ReviewHookEvent,
pub command: String,
pub args: Vec<String>,
#[serde(default = "default_hook_stdin")]
pub stdin: ReviewHookStdin,
#[serde(default = "default_hook_blocking")]
pub blocking: bool,
#[serde(default = "default_hook_timeout_ms")]
pub timeout_ms: u64,
pub save_editor: bool,
pub show: Vec<String>,
}
impl Default for ReviewActionConfig {
fn default() -> Self {
Self {
id: String::new(),
label: String::new(),
key: None,
on: ReviewHookEvent::ReviewReady,
command: String::new(),
args: Vec::new(),
stdin: ReviewHookStdin::Json,
blocking: true,
timeout_ms: default_hook_timeout_ms(),
save_editor: true,
show: vec!["command_palette".to_string(), "review_editor".to_string()],
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
#[allow(dead_code)]
pub struct ReviewProviderCommandConfig {
pub command: String,
pub args: Vec<String>,
#[serde(default = "default_hook_timeout_ms")]
pub timeout_ms: u64,
}
impl Default for ReviewProviderCommandConfig {
fn default() -> Self {
Self {
command: String::new(),
args: Vec::new(),
timeout_ms: default_hook_timeout_ms(),
}
}
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default)]
#[allow(dead_code)]
pub struct ReviewProviderCommandsConfig {
pub whoami: Option<ReviewProviderCommandConfig>,
pub pr_find: Option<ReviewProviderCommandConfig>,
pub pr_get: Option<ReviewProviderCommandConfig>,
pub comments_list: Option<ReviewProviderCommandConfig>,
pub comments_create: Option<ReviewProviderCommandConfig>,
pub comments_update: Option<ReviewProviderCommandConfig>,
pub comments_delete: Option<ReviewProviderCommandConfig>,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default)]
#[allow(dead_code)]
pub struct ReviewProviderConfig {
pub hosts: Vec<String>,
pub commands: ReviewProviderCommandsConfig,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default)]
pub struct ReviewConfig {
pub dir: Option<PathBuf>,
pub providers: BTreeMap<String, ReviewProviderConfig>,
pub hooks: Vec<ReviewHookConfig>,
pub actions: Vec<ReviewActionConfig>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
pub struct SelectionActionConfig {
pub id: String,
pub label: String,
pub key: Option<String>,
pub message: Option<String>,
pub failure_message: Option<String>,
pub command: String,
pub args: Vec<String>,
#[serde(default = "default_hook_stdin")]
pub stdin: ReviewHookStdin,
#[serde(default = "default_hook_blocking")]
pub blocking: bool,
#[serde(default = "default_hook_timeout_ms")]
pub timeout_ms: u64,
}
impl Default for SelectionActionConfig {
fn default() -> Self {
Self {
id: String::new(),
label: String::new(),
key: None,
message: None,
failure_message: None,
command: String::new(),
args: Vec::new(),
stdin: ReviewHookStdin::Json,
blocking: true,
timeout_ms: default_hook_timeout_ms(),
}
}
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default)]
pub struct SelectionConfig {
pub actions: Vec<SelectionActionConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeybindingsConfig {
pub modes: BTreeMap<String, BTreeMap<String, Vec<String>>>,
}
impl<'de> Deserialize<'de> for KeybindingsConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = toml::Value::deserialize(deserializer)?;
let Some(table) = value.as_table() else {
eprintln!("Warning: Ignoring [keybindings]: expected a table");
return Ok(Self::default());
};
let mut modes = BTreeMap::new();
for (mode, mode_value) in table {
let Some(mode_table) = mode_value.as_table() else {
eprintln!("Warning: Ignoring [keybindings.{}]: expected a table", mode);
continue;
};
let mut actions = BTreeMap::new();
for (action, action_value) in mode_table {
let Some(keys) = action_value.as_array() else {
eprintln!(
"Warning: Ignoring keybinding '{}.{}': expected an array of strings",
mode, action
);
continue;
};
let mut parsed_keys = Vec::new();
let mut valid = true;
for key in keys {
let Some(key) = key.as_str() else {
eprintln!(
"Warning: Ignoring keybinding '{}.{}': expected an array of strings",
mode, action
);
valid = false;
break;
};
parsed_keys.push(key.to_string());
}
if valid {
actions.insert(action.clone(), parsed_keys);
}
}
modes.insert(mode.clone(), actions);
}
Ok(Self { modes })
}
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub ui: UiConfig,
pub playback: PlaybackConfig,
pub files: FilesConfig,
pub navigation: NavigationConfig,
pub no_step: NoStepConfig,
pub comments: CommentsConfig,
pub review: ReviewConfig,
pub selection: SelectionConfig,
pub editor: EditorConfig,
pub keybindings: KeybindingsConfig,
}
fn append_config_array(path: &[String]) -> bool {
matches!(path, [review, name] if review == "review" && matches!(name.as_str(), "hooks" | "actions"))
|| matches!(path, [selection, name] if selection == "selection" && name == "actions")
}
fn merge_config_value_at(base: &mut toml::Value, overlay: toml::Value, path: &mut Vec<String>) {
if append_config_array(path) {
match overlay {
toml::Value::Array(mut overlay_array) => {
if let Some(base_array) = base.as_array_mut() {
base_array.append(&mut overlay_array);
} else {
*base = toml::Value::Array(overlay_array);
}
return;
}
overlay => {
*base = overlay;
return;
}
}
}
match (base, overlay) {
(toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
for (key, value) in overlay_table {
path.push(key.clone());
if let Some(base_value) = base_table.get_mut(&key) {
merge_config_value_at(base_value, value, path);
} else {
base_table.insert(key, value);
}
path.pop();
}
}
(base, overlay) => *base = overlay,
}
}
fn merge_config_value(base: &mut toml::Value, overlay: toml::Value) {
merge_config_value_at(base, overlay, &mut Vec::new());
}
fn set_document_value(
document: &mut toml_edit::DocumentMut,
keys: &[&str],
value: Option<toml_edit::Value>,
) -> Result<(), String> {
let Some((key, parents)) = keys.split_last() else {
return Err("Config key path is empty".to_string());
};
let mut table = document.as_table_mut();
for parent in parents {
let item = table
.entry(parent)
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
table = item
.as_table_mut()
.ok_or_else(|| format!("Config key {} is not a table", parents.join(".")))?;
}
let Some(value) = value else {
table.remove(key);
return Ok(());
};
if let Some(existing) = table.get_mut(key) {
let existing = existing
.as_value_mut()
.ok_or_else(|| format!("Config key {} is not a value", keys.join(".")))?;
let decor = existing.decor().clone();
*existing = value;
*existing.decor_mut() = decor;
} else {
table.insert(key, toml_edit::Item::Value(value));
}
Ok(())
}
impl Config {
fn config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
paths.push(PathBuf::from(xdg).join("oyo").join("config.toml"));
}
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".config").join("oyo").join("config.toml"));
}
if let Some(config_dir) = dirs::config_dir() {
let platform_path = config_dir.join("oyo").join("config.toml");
if !paths.contains(&platform_path) {
paths.push(platform_path);
}
}
paths
}
pub fn config_path() -> Option<PathBuf> {
Self::config_paths().into_iter().find(|p| p.exists())
}
pub(crate) fn config_path_for_write() -> Option<PathBuf> {
Self::config_path().or_else(|| Self::config_paths().into_iter().next())
}
#[cfg(test)]
pub(crate) fn write_config_value(
path: &Path,
keys: &[&str],
value: toml_edit::Value,
) -> Result<(), String> {
Self::write_config_values(path, &[(keys.to_vec(), Some(value))])
}
pub(crate) fn write_config_values(
path: &Path,
changes: &[(Vec<&str>, Option<toml_edit::Value>)],
) -> Result<(), String> {
let content = if path.exists() {
fs::read_to_string(path)
.map_err(|error| format!("Failed to read config {}: {error}", path.display()))?
} else {
String::new()
};
let mut document = content
.parse::<toml_edit::DocumentMut>()
.map_err(|error| format!("Failed to parse config {}: {error}", path.display()))?;
for (keys, value) in changes {
set_document_value(&mut document, keys, value.clone())?;
}
let target = match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
fs::canonicalize(path).map_err(|error| {
format!(
"Failed to resolve config symlink {}: {error}",
path.display()
)
})?
}
_ => path.to_path_buf(),
};
let parent = target
.parent()
.ok_or_else(|| format!("Config path has no parent: {}", target.display()))?;
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
let temporary = parent.join(format!(".config.toml.{}.tmp", std::process::id()));
fs::write(&temporary, document.to_string())
.map_err(|error| format!("Failed to write config {}: {error}", temporary.display()))?;
if let Ok(metadata) = fs::metadata(&target) {
let _ = fs::set_permissions(&temporary, metadata.permissions());
}
fs::rename(&temporary, &target).map_err(|error| {
let _ = fs::remove_file(&temporary);
format!("Failed to replace config {}: {error}", target.display())
})
}
pub(crate) fn load_from_path(path: &Path) -> Result<Self, String> {
if !path.exists() {
return Ok(Self::default());
}
Self::read_config_value(path)?
.try_into()
.map_err(|error| format!("Failed to load config {}: {error}", path.display()))
}
fn read_config_value(path: &Path) -> Result<toml::Value, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read config {}: {}", path.display(), e))?;
toml::from_str(&content)
.map_err(|e| format!("Failed to parse config {}: {}", path.display(), e))
}
fn merge_user_config(merged: &mut toml::Value) {
let Some(path) = Self::config_path() else {
return;
};
match Self::read_config_value(&path) {
Ok(value) => merge_config_value(merged, value),
Err(error) => eprintln!("Warning: {error}"),
}
}
pub fn load_with_extra(extra_paths: &[PathBuf]) -> Result<Self, String> {
let mut merged = toml::Value::Table(toml::map::Map::new());
Self::merge_user_config(&mut merged);
for path in extra_paths {
let value = Self::read_config_value(path)?;
merge_config_value(&mut merged, value);
}
merged
.try_into()
.map_err(|e| format!("Failed to load merged config: {e}"))
}
pub fn load() -> Self {
let mut merged = toml::Value::Table(toml::map::Map::new());
Self::merge_user_config(&mut merged);
merged
.try_into()
.map_err(|e| eprintln!("Warning: Failed to load config: {}", e))
.unwrap_or_default()
}
pub fn parse_view_mode(&self) -> Option<crate::app::ViewMode> {
self.ui.view_mode.as_ref().and_then(|s| match s.as_str() {
"unified" => Some(crate::app::ViewMode::UnifiedPane),
"split" | "sbs" => Some(crate::app::ViewMode::Split),
"evolution" | "evo" => Some(crate::app::ViewMode::Evolution),
"blame" => Some(crate::app::ViewMode::Blame),
"preview" => Some(crate::app::ViewMode::Preview),
_ => None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_write_changes_one_key_and_preserves_comments() {
let dir = std::env::temp_dir().join(format!("oyo-settings-config-{}", std::process::id()));
let path = dir.join("config.toml");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
&path,
"# header\n[ui]\n# prefer unified\nview_mode = \"unified\"\n# keep line wrap choice\nline_wrap = false # inline note\n# keep this\nwatch = true\n\n[playback]\nanimation = false\n",
)
.unwrap();
Config::write_config_value(&path, &["ui", "line_wrap"], toml_edit::Value::from(true))
.unwrap();
let updated = fs::read_to_string(&path).unwrap();
assert_eq!(
updated,
"# header\n[ui]\n# prefer unified\nview_mode = \"unified\"\n# keep line wrap choice\nline_wrap = true # inline note\n# keep this\nwatch = true\n\n[playback]\nanimation = false\n"
);
Config::write_config_value(&path, &["ui", "view_mode"], toml_edit::Value::from("split"))
.unwrap();
let updated = fs::read_to_string(&path).unwrap();
assert_eq!(
updated,
"# header\n[ui]\n# prefer unified\nview_mode = \"split\"\n# keep line wrap choice\nline_wrap = true # inline note\n# keep this\nwatch = true\n\n[playback]\nanimation = false\n"
);
Config::write_config_value(
&path,
&["ui", "theme", "name"],
toml_edit::Value::from("nord"),
)
.unwrap();
let updated = fs::read_to_string(&path).unwrap();
assert!(updated.contains("[ui.theme]\nname = \"nord\""));
assert!(updated.contains("# keep this\nwatch = true"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn settings_write_preserves_nested_bool_and_enum_decor() {
let dir = std::env::temp_dir().join(format!("oyo-settings-nested-{}", std::process::id()));
let path = dir.join("config.toml");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(
&path,
"[ui.diff]\n# keep background choice\nbg = true # full line\n\n[files]\n# keep count choice\ncounts = \"active\" # sidebar\n",
)
.unwrap();
Config::write_config_value(&path, &["ui", "diff", "bg"], false.into()).unwrap();
Config::write_config_value(&path, &["files", "counts"], "focused".into()).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"[ui.diff]\n# keep background choice\nbg = false # full line\n\n[files]\n# keep count choice\ncounts = \"focused\" # sidebar\n"
);
let _ = fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn settings_write_updates_symlink_target_without_replacing_link() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("oyo-settings-symlink-{}", std::process::id()));
let target_dir = dir.join("dotfiles");
let path = dir.join("config.toml");
let target = target_dir.join("oyo.toml");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&target_dir).unwrap();
fs::write(&target, "[ui]\nline_wrap = false\n").unwrap();
symlink(&target, &path).unwrap();
Config::write_config_value(&path, &["ui", "line_wrap"], true.into()).unwrap();
assert!(fs::symlink_metadata(&path)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
fs::read_to_string(&target).unwrap(),
"[ui]\nline_wrap = true\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn custom_theme_lookup_ignores_filename_case() {
let dir = std::env::temp_dir().join(format!("oyo-theme-case-{}", std::process::id()));
let path = dir.join("MyTheme.json");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(&path, "{}").unwrap();
assert_eq!(find_theme_candidate(&dir, "mytheme.json"), Some(path));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn light_theme_diff_foregrounds_meet_contrast_floor() {
for name in ["everforest", "github", "flexoki"] {
let theme = ThemeConfig {
name: Some(name.to_string()),
..ThemeConfig::default()
}
.resolve(true);
for (foreground, background) in [
(theme.insert_base(), theme.diff_added_bg.unwrap()),
(theme.delete_base(), theme.diff_removed_bg.unwrap()),
] {
assert!(
color::contrast_ratio(foreground, background).unwrap() >= 4.5,
"{name}: {foreground:?} on {background:?}"
);
}
}
}
#[test]
fn dark_diff_foregrounds_stay_on_the_theme_gradient() {
let theme = ThemeConfig {
name: Some("everforest".to_string()),
..ThemeConfig::default()
}
.resolve(false);
let insert = color::hsl_to_rgb(theme.insert.base);
let delete = color::hsl_to_rgb(theme.delete.base);
assert_eq!(
theme.insert_base(),
Color::Rgb(insert.r, insert.g, insert.b)
);
assert_eq!(
theme.delete_base(),
Color::Rgb(delete.r, delete.g, delete.b)
);
}
#[test]
fn ui_defaults_to_no_step_mode() {
assert!(!Config::default().ui.stepping);
}
#[test]
fn ui_shows_scrollbar_by_default() {
assert!(Config::default().ui.scrollbar);
}
#[test]
fn quit_confirmation_defaults_on_and_can_be_disabled() {
assert!(Config::default().ui.confirm_quit);
let config: Config = toml::from_str("[ui]\nconfirm_quit = false\n").unwrap();
assert!(!config.ui.confirm_quit);
}
#[test]
fn context_folding_defaults_on_and_has_no_dense_mode() {
assert_eq!(
Config::default().ui.fold_context,
FoldContextMode::Expandable
);
for (value, expected) in [
("off", FoldContextMode::Off),
("expandable", FoldContextMode::Expandable),
("on", FoldContextMode::Expandable),
] {
let config: Config =
toml::from_str(&format!("[ui]\nfold_context = \"{value}\"\n")).unwrap();
assert_eq!(config.ui.fold_context, expected);
}
for value in ["dense", "counts"] {
assert!(
toml::from_str::<Config>(&format!("[ui]\nfold_context = \"{value}\"\n")).is_err()
);
}
let config: Config = toml::from_str("[ui]\nfold_context_lines = 0\n").unwrap();
assert_eq!(config.ui.fold_context_lines, 0);
}
#[test]
fn extent_marker_left_overrides_legacy_extent_marker() {
let config: Config = toml::from_str(
r#"
[ui]
extent_marker = "A"
extent_marker_left = "B"
extent_marker_deleted = "D"
"#,
)
.unwrap();
assert_eq!(config.ui.extent_marker_left(), "B");
assert_eq!(config.ui.extent_marker_deleted, "D");
}
#[test]
fn legacy_extent_marker_still_sets_left_marker() {
let config: Config = toml::from_str(
r#"
[ui]
extent_marker = "A"
"#,
)
.unwrap();
assert_eq!(config.ui.extent_marker_left(), "A");
}
#[test]
fn preview_change_bars_are_on_by_default() {
assert!(Config::default().ui.diff.preview_change_bars);
}
#[test]
fn toasts_are_on_by_default() {
assert!(Config::default().ui.toasts.enabled);
assert_eq!(
Config::default().ui.toasts.position,
ToastPositionConfig::BottomRight
);
}
#[test]
fn toast_position_can_be_changed() {
let config: Config = toml::from_str(
r#"
[ui.toasts]
position = "top_left"
"#,
)
.unwrap();
assert_eq!(config.ui.toasts.position, ToastPositionConfig::TopLeft);
}
#[test]
fn toasts_can_be_disabled() {
let config: Config = toml::from_str(
r#"
[ui.toasts]
enabled = false
"#,
)
.unwrap();
assert!(!config.ui.toasts.enabled);
}
#[test]
fn preview_change_bars_can_be_disabled() {
let config: Config = toml::from_str(
r#"
[ui.diff]
preview_change_bars = false
"#,
)
.unwrap();
assert!(!config.ui.diff.preview_change_bars);
}
#[test]
fn review_provider_contract_config_parses() {
let config: Config = toml::from_str(
r#"
[review.providers.example]
hosts = ["git.example.com"]
[review.providers.example.commands.whoami]
command = "example-oyo-provider"
args = ["whoami"]
[review.providers.example.commands.comments_list]
command = "example-oyo-provider"
args = ["comments", "list"]
timeout_ms = 10000
"#,
)
.unwrap();
let provider = config.review.providers.get("example").unwrap();
assert_eq!(provider.hosts, vec!["git.example.com"]);
assert_eq!(
provider.commands.whoami.as_ref().unwrap().command,
"example-oyo-provider"
);
assert_eq!(
provider.commands.comments_list.as_ref().unwrap().timeout_ms,
10000
);
}
#[test]
fn extra_config_merges_tables_and_appends_actions() {
let mut base: toml::Value = toml::from_str(
r#"
[ui]
topbar = false
[keybindings.normal]
step_down = ["j"]
[[review.hooks]]
id = "base"
command = "base-hook"
"#,
)
.unwrap();
let overlay: toml::Value = toml::from_str(
r#"
[ui]
topbar = true
[keybindings.normal]
step_down = ["down"]
step_up = ["up"]
[[review.hooks]]
id = "extra"
command = "extra-hook"
[[review.actions]]
id = "send"
command = "send-hook"
[[selection.actions]]
id = "ask"
label = "Ask agent"
key = "a"
message = "Sent to agent"
failure_message = "Could not send to agent"
command = "ask-hook"
"#,
)
.unwrap();
merge_config_value(&mut base, overlay);
let config: Config = base.try_into().unwrap();
assert!(config.ui.topbar);
assert_eq!(config.review.hooks.len(), 2);
assert_eq!(config.review.hooks[0].id, "base");
assert_eq!(config.review.hooks[1].id, "extra");
assert_eq!(config.review.actions.len(), 1);
assert_eq!(config.selection.actions.len(), 1);
assert_eq!(config.selection.actions[0].id, "ask");
assert_eq!(config.selection.actions[0].key.as_deref(), Some("a"));
assert_eq!(
config.selection.actions[0].message.as_deref(),
Some("Sent to agent")
);
assert_eq!(
config.selection.actions[0].failure_message.as_deref(),
Some("Could not send to agent")
);
assert_eq!(
config.keybindings.modes["normal"]["step_down"],
vec!["down".to_string()]
);
assert_eq!(
config.keybindings.modes["normal"]["step_up"],
vec!["up".to_string()]
);
}
}