use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SortOrder {
Alphabetical,
RecentVisit,
LatestChanges,
Custom,
}
fn default_sort_by() -> SortOrder {
SortOrder::Custom
}
fn default_visits() -> std::collections::HashMap<String, u64> {
std::collections::HashMap::new()
}
fn default_poll_interval_ms() -> u64 {
100
}
fn default_max_commits() -> usize {
500
}
fn default_graph_max_commits() -> usize {
1000
}
fn default_detail_cache_ttl_secs() -> u64 {
30
}
fn default_tab_ttl_secs() -> u64 {
60
}
fn default_page_size() -> usize {
10
}
fn default_git_app() -> String {
"gitui".to_string()
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ThemeConfig {
#[serde(default = "default_accent")]
pub accent: String,
#[serde(default = "default_warning")]
pub warning: String,
#[serde(default = "default_danger")]
pub danger: String,
#[serde(default = "default_success")]
pub success: String,
#[serde(default = "default_border_type")]
pub border_type: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct FzfConfig {
#[serde(default = "default_fzf_max_depth")]
pub max_depth: usize,
#[serde(default = "default_fzf_excludes")]
pub excludes: Vec<String>,
#[serde(default = "default_fzf_start_dir")]
pub start_dir: String,
#[serde(default = "default_fzf_git_only")]
pub git_only: bool,
#[serde(default = "default_fzf_enabled")]
pub enabled: bool,
}
impl Default for ThemeConfig {
fn default() -> Self {
default_theme()
}
}
impl Default for FzfConfig {
fn default() -> Self {
default_fzf()
}
}
fn default_accent() -> String {
"cyan".to_string()
}
fn default_warning() -> String {
"yellow".to_string()
}
fn default_danger() -> String {
"red".to_string()
}
fn default_success() -> String {
"green".to_string()
}
fn default_border_type() -> String {
"rounded".to_string()
}
fn default_theme() -> ThemeConfig {
ThemeConfig {
accent: default_accent(),
warning: default_warning(),
danger: default_danger(),
success: default_success(),
border_type: default_border_type(),
}
}
fn default_theme_name() -> String {
"default".to_string()
}
fn default_fzf_max_depth() -> usize {
6
}
fn default_fzf_excludes() -> Vec<String> {
vec![]
}
fn default_fzf_start_dir() -> String {
dirs::home_dir()
.map(|p| {
let mut s = p.to_string_lossy().into_owned();
if !s.ends_with(std::path::MAIN_SEPARATOR) {
s.push(std::path::MAIN_SEPARATOR);
}
s
})
.unwrap_or_else(|| "/".to_string())
}
fn default_fzf_git_only() -> bool {
true
}
fn default_fzf_enabled() -> bool {
true
}
fn default_compatibility_mode() -> bool {
true
}
fn default_resync_on_tab_change() -> bool {
false
}
fn default_enable_commit_signatures() -> bool {
false
}
fn default_fzf() -> FzfConfig {
FzfConfig {
max_depth: default_fzf_max_depth(),
excludes: default_fzf_excludes(),
start_dir: default_fzf_start_dir(),
git_only: default_fzf_git_only(),
enabled: default_fzf_enabled(),
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub items: Vec<String>,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64,
#[serde(default = "default_max_commits")]
pub max_commits: usize,
#[serde(default = "default_graph_max_commits")]
pub graph_max_commits: usize,
#[serde(default = "default_detail_cache_ttl_secs")]
pub detail_cache_ttl_secs: u64,
#[serde(default = "default_tab_ttl_secs")]
pub tab_ttl_secs: u64,
#[serde(default = "default_page_size")]
pub page_size: usize,
#[serde(default = "default_sort_by")]
pub sort_by: SortOrder,
#[serde(default = "default_visits")]
pub visits: std::collections::HashMap<String, u64>,
#[serde(default)]
pub sort_reverse: bool,
#[serde(default)]
pub pinned: std::collections::HashSet<String>,
#[serde(skip)]
pub theme: ThemeConfig,
#[serde(rename = "theme", default = "default_theme_name")]
pub theme_name: String,
#[serde(default = "default_fzf")]
pub fzf: FzfConfig,
#[serde(default = "default_git_app")]
pub git_app: String,
#[serde(default = "default_compatibility_mode")]
pub compatibility_mode: bool,
#[serde(default = "default_resync_on_tab_change")]
pub resync_on_tab_change: bool,
#[serde(default = "default_enable_commit_signatures")]
pub enable_commit_signatures: bool,
}
impl Config {
pub fn sym(&self, key: &str) -> &'static str {
if self.compatibility_mode {
match key {
"branch" => "* ",
"git_repo" => "G ",
"arrow_down" => "v",
"arrow_right" => ">",
"folder_tree_expanded" => "v ",
"folder_tree_collapsed" => "> ",
"file_tree" => " - ",
"folder" => "[D]",
"file" => "[F]",
"pinned" => "[P]",
"action" => "[!]",
"warning" => "! ",
"close" => "x",
"bullet_empty" => "o",
"bullet_filled" => "*",
"star" => "*",
"block" => "#",
"bar" => "|",
"esc" => "ESC",
"backspace" => "Backspace",
"tab" => "Tab",
"shift" => "Shift",
"enter" => "Enter",
"up" => "^",
"down" => "v",
"page_up" => "PgUp",
"page_down" => "PgDn",
"transfer" => "<->",
"up_down" => "^/v",
"selection_mark" => "> ",
_ => "",
}
} else {
match key {
"branch" => " ",
"git_repo" => "⎇ ",
"arrow_down" => "▼",
"arrow_right" => "▶",
"folder_tree_expanded" => "▼ ",
"folder_tree_collapsed" => "> ",
"file_tree" => " 📄 ",
"folder" => "📁 ",
"file" => "📄 ",
"pinned" => "📌 ",
"action" => "⚡ ",
"warning" => "⚠ ",
"close" => "✕",
"bullet_empty" => "○",
"bullet_filled" => "●",
"star" => "★",
"block" => "█",
"bar" => "▍",
"esc" => "⎋",
"backspace" => "⌫",
"tab" => "⇥",
"shift" => "⇧",
"enter" => "↵",
"up" => "↑",
"down" => "↓",
"page_up" => "⇞",
"page_down" => "⇟",
"transfer" => "⇆",
"up_down" => "↑↓",
"selection_mark" => "▌ ",
_ => "",
}
}
}
}
fn home_gitwig_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitwig")
}
pub fn load_config(cli_path: Option<PathBuf>) -> Result<(Config, PathBuf), Box<dyn Error>> {
if let Some(path) = cli_path {
if path.exists() {
let contents = fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&contents)?;
let themes_dir = path.parent().unwrap_or(&path).join("themes");
fs::create_dir_all(&themes_dir)?;
write_popular_themes(&themes_dir)?;
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
let theme_contents = fs::read_to_string(&theme_path)?;
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
}
} else {
let legacy_theme_path = path.with_file_name("theme.toml");
if legacy_theme_path.exists() {
let _ = fs::copy(&legacy_theme_path, themes_dir.join("default.theme"));
let _ = fs::remove_file(&legacy_theme_path);
}
let theme_serialized = toml::to_string_pretty(&config.theme)?;
fs::write(&theme_path, theme_serialized)?;
}
return Ok((config, path));
}
let fallback_theme = default_theme();
let fallback_theme_name = default_theme_name();
let themes_dir = path.parent().unwrap_or(&path).join("themes");
fs::create_dir_all(&themes_dir)?;
write_popular_themes(&themes_dir)?;
let theme_path = themes_dir.join(format!("{}.theme", fallback_theme_name));
let theme_serialized = toml::to_string_pretty(&fallback_theme)?;
fs::write(&theme_path, theme_serialized)?;
return Ok((
Config {
items: vec![],
poll_interval_ms: default_poll_interval_ms(),
max_commits: default_max_commits(),
graph_max_commits: default_graph_max_commits(),
detail_cache_ttl_secs: default_detail_cache_ttl_secs(),
tab_ttl_secs: default_tab_ttl_secs(),
page_size: default_page_size(),
sort_by: default_sort_by(),
visits: default_visits(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
theme_name: fallback_theme_name,
theme: fallback_theme,
fzf: default_fzf(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
},
path,
));
}
let gitwig_dir = home_gitwig_dir();
fs::create_dir_all(&gitwig_dir)?;
let canonical = gitwig_dir.join("config.toml");
let themes_dir = gitwig_dir.join("themes");
fs::create_dir_all(&themes_dir)?;
write_popular_themes(&themes_dir)?;
if canonical.exists() {
let contents = fs::read_to_string(&canonical)?;
let mut config: Config = toml::from_str(&contents)?;
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
let theme_contents = fs::read_to_string(&theme_path)?;
let theme: ThemeConfig = toml::from_str(&theme_contents)?;
config.theme = theme;
} else {
let legacy_theme_path = gitwig_dir.join("theme.toml");
if legacy_theme_path.exists() {
let _ = fs::copy(&legacy_theme_path, themes_dir.join("default.theme"));
let _ = fs::remove_file(&legacy_theme_path);
}
let theme_serialized = toml::to_string_pretty(&config.theme)?;
fs::write(&theme_path, theme_serialized)?;
}
return Ok((config, canonical));
}
if let Some(source) = find_legacy_config() {
fs::copy(&source, &canonical)?;
let contents = fs::read_to_string(&canonical)?;
let mut config: Config = toml::from_str(&contents)?;
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
let theme_contents = fs::read_to_string(&theme_path)?;
let theme: ThemeConfig = toml::from_str(&theme_contents)?;
config.theme = theme;
} else {
let legacy_theme_path = gitwig_dir.join("theme.toml");
if legacy_theme_path.exists() {
let _ = fs::copy(&legacy_theme_path, themes_dir.join("default.theme"));
let _ = fs::remove_file(&legacy_theme_path);
}
let theme_serialized = toml::to_string_pretty(&config.theme)?;
fs::write(&theme_path, theme_serialized)?;
}
return Ok((config, canonical));
}
let fallback = Config {
items: vec![
"Nice job. You forgot the config, genius.".to_string(),
"Still looking... it's not here either.".to_string(),
],
poll_interval_ms: default_poll_interval_ms(),
max_commits: default_max_commits(),
graph_max_commits: default_graph_max_commits(),
detail_cache_ttl_secs: default_detail_cache_ttl_secs(),
tab_ttl_secs: default_tab_ttl_secs(),
page_size: default_page_size(),
sort_by: default_sort_by(),
visits: default_visits(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
theme_name: default_theme_name(),
theme: default_theme(),
fzf: default_fzf(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
};
save_config(&fallback, &canonical)?;
let theme_path = themes_dir.join(format!("{}.theme", fallback.theme_name));
let theme_serialized = toml::to_string_pretty(&fallback.theme)?;
fs::write(&theme_path, theme_serialized)?;
Ok((fallback, canonical))
}
fn write_popular_themes(themes_dir: &Path) -> Result<(), Box<dyn Error>> {
let popular_themes = [
(
"dracula",
r#"accent = "lightmagenta"
warning = "lightyellow"
danger = "lightred"
success = "lightgreen"
border_type = "rounded"
"#,
),
(
"forest",
r#"accent = "lightgreen"
warning = "yellow"
danger = "lightred"
success = "green"
border_type = "rounded"
"#,
),
(
"gruvbox",
r#"accent = "yellow"
warning = "lightyellow"
danger = "red"
success = "green"
border_type = "plain"
"#,
),
(
"monokai",
r#"accent = "lightyellow"
warning = "yellow"
danger = "red"
success = "green"
border_type = "rounded"
"#,
),
(
"nord",
r#"accent = "lightblue"
warning = "yellow"
danger = "red"
success = "green"
border_type = "rounded"
"#,
),
(
"oceanic",
r#"accent = "lightcyan"
warning = "yellow"
danger = "red"
success = "lightgreen"
border_type = "rounded"
"#,
),
];
for (name, content) in popular_themes {
let theme_path = themes_dir.join(format!("{}.theme", name));
if !theme_path.exists() {
fs::write(theme_path, content)?;
}
}
Ok(())
}
fn find_legacy_config() -> Option<PathBuf> {
let local = PathBuf::from("config/config.toml");
if local.exists() {
return Some(local);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let p = dir.join("config/config.toml");
if p.exists() {
return Some(p);
}
}
}
if let Some(home) = dirs::home_dir() {
let p = home.join(".twig/config.toml");
if p.exists() {
return Some(p);
}
}
if let Some(p) = dirs::config_dir().map(|d| d.join("gitwig/config.toml")) {
if p.exists() {
return Some(p);
}
}
if let Some(p) = dirs::config_dir().map(|d| d.join("twig/config.toml")) {
if p.exists() {
return Some(p);
}
}
None
}
pub fn save_config(config: &Config, path: &Path) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
let serialized = toml::to_string_pretty(config)?;
fs::write(path, serialized)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_theme_separation_load_and_save() {
let unique_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_theme_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
let themes_dir = test_dir.join("themes");
let theme_path = themes_dir.join("default.theme");
let (config, path) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert!(!config_path.exists());
assert!(theme_path.exists());
assert_eq!(config.theme.accent, "cyan");
assert_eq!(config.theme_name, "default");
let theme_content = fs::read_to_string(&theme_path).unwrap();
assert!(theme_content.contains("accent = \"cyan\""));
save_config(&config, &config_path).unwrap();
assert!(config_path.exists());
let config_content = fs::read_to_string(&config_path).unwrap();
assert!(!config_content.contains("[theme]"));
assert!(config_content.contains("theme = \"default\""));
let custom_theme = r#"accent = "magenta"
warning = "yellow"
danger = "red"
success = "green"
border_type = "double"
"#;
fs::write(&theme_path, custom_theme).unwrap();
let (loaded_config, _) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(loaded_config.theme.accent, "magenta");
assert_eq!(loaded_config.theme.border_type, "double");
save_config(&loaded_config, &config_path).unwrap();
let config_content_after_save = fs::read_to_string(&config_path).unwrap();
assert!(!config_content_after_save.contains("[theme]"));
assert!(config_content_after_save.contains("theme = \"default\""));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_write_popular_themes_creates_files() {
let unique_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let test_dir =
std::env::temp_dir().join(format!("gitwig_test_popular_themes_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
write_popular_themes(&test_dir).unwrap();
assert!(test_dir.join("dracula.theme").exists());
assert!(test_dir.join("forest.theme").exists());
assert!(test_dir.join("gruvbox.theme").exists());
assert!(test_dir.join("monokai.theme").exists());
assert!(test_dir.join("nord.theme").exists());
assert!(test_dir.join("oceanic.theme").exists());
let contents = fs::read_to_string(test_dir.join("oceanic.theme")).unwrap();
assert!(contents.contains("accent = \"lightcyan\""));
let _ = fs::remove_dir_all(&test_dir);
}
}