use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::state::{AppState, load_state, save_state};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SortOrder {
Alphabetical,
RecentVisit,
LatestChanges,
Custom,
}
impl SortOrder {
pub fn next(self) -> Self {
match self {
SortOrder::Custom => SortOrder::Alphabetical,
SortOrder::Alphabetical => SortOrder::RecentVisit,
SortOrder::RecentVisit => SortOrder::LatestChanges,
SortOrder::LatestChanges => SortOrder::Custom,
}
}
pub fn prev(self) -> Self {
match self {
SortOrder::Custom => SortOrder::LatestChanges,
SortOrder::Alphabetical => SortOrder::Custom,
SortOrder::RecentVisit => SortOrder::Alphabetical,
SortOrder::LatestChanges => SortOrder::RecentVisit,
}
}
pub fn display_name(self) -> &'static str {
match self {
SortOrder::Custom => "Custom",
SortOrder::Alphabetical => "Alphabetical",
SortOrder::RecentVisit => "Recent Visit",
SortOrder::LatestChanges => "Latest Changes",
}
}
}
fn default_sort_by() -> SortOrder {
SortOrder::Custom
}
fn default_poll_interval_ms() -> u64 {
100
}
fn default_fetch_timeout_secs() -> u64 {
30
}
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()
}
pub fn ssh_command_val() -> &'static str {
if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
"ssh -o BatchMode=yes -o StrictHostKeyChecking=yes"
} else {
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
}
}
#[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, Default)]
pub struct RepoConfig {
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub page_size: Option<usize>,
#[serde(default)]
pub max_commits: Option<usize>,
#[serde(default)]
pub resync_on_tab_change: Option<bool>,
#[serde(default)]
pub auto_fetch_interval_mins: Option<u64>,
#[serde(default)]
pub editor: Option<String>,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)]
pub struct LabelConfig {
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub page_size: Option<usize>,
#[serde(default)]
pub max_commits: Option<usize>,
#[serde(default)]
pub resync_on_tab_change: Option<bool>,
#[serde(default)]
pub auto_fetch_interval_mins: Option<u64>,
#[serde(default)]
pub editor: Option<String>,
#[serde(default)]
pub sort_by: Option<SortOrder>,
#[serde(default)]
pub sort_reverse: Option<bool>,
#[serde(default)]
pub view_mode: Option<HomeViewMode>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ScanConfig {
#[serde(default = "default_scan_max_depth")]
pub max_depth: usize,
#[serde(default = "default_scan_excludes")]
pub excludes: Vec<String>,
#[serde(default = "default_scan_start_dir")]
pub start_dir: String,
#[serde(default = "default_scan_git_only")]
pub git_only: bool,
}
impl Default for ThemeConfig {
fn default() -> Self {
default_theme()
}
}
impl Default for ScanConfig {
fn default() -> Self {
default_scan()
}
}
impl Default for Config {
fn default() -> Self {
Config {
items: Vec::new(),
poll_interval_ms: default_poll_interval_ms(),
fetch_timeout_secs: default_fetch_timeout_secs(),
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(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
label_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: Vec::new(),
theme: ThemeConfig::default(),
theme_name: default_theme_name(),
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: default_compatibility_mode(),
resync_on_tab_change: default_resync_on_tab_change(),
prompt_cwd_repo: default_prompt_cwd_repo(),
enable_commit_signatures: default_enable_commit_signatures(),
ssh_strict_host_checking: default_ssh_strict_host_checking(),
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
show_separators: default_show_separators(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
enable_watch_dirs: default_enable_watch_dirs(),
}
}
}
fn default_ssh_strict_host_checking() -> bool {
false
}
fn default_editor() -> String {
std::env::var("EDITOR").or_else(|_| std::env::var("VISUAL")).unwrap_or_else(|_| {
if cfg!(target_os = "windows") { "notepad".to_string() } else { "vim".to_string() }
})
}
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_scan_max_depth() -> usize {
6
}
pub fn default_scan_excludes() -> Vec<String> {
vec![
"node_modules".to_string(),
"target".to_string(),
"venv".to_string(),
".venv".to_string(),
"checkout".to_string(),
]
}
fn default_scan_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_scan_git_only() -> bool {
true
}
fn default_compatibility_mode() -> bool {
true
}
fn default_show_grouping() -> bool {
true
}
fn default_resync_on_tab_change() -> bool {
false
}
fn default_enable_commit_signatures() -> bool {
false
}
fn default_auto_fetch_interval_mins() -> u64 {
10
}
fn default_show_system_stats() -> bool {
false
}
fn default_show_separators() -> bool {
true
}
fn default_stale_threshold_months() -> u32 {
1
}
fn default_show_stale_projects() -> bool {
true
}
fn default_enable_watch_dirs() -> bool {
true
}
fn default_prompt_cwd_repo() -> bool {
true
}
fn default_scan() -> ScanConfig {
ScanConfig {
max_depth: default_scan_max_depth(),
excludes: default_scan_excludes(),
start_dir: default_scan_start_dir(),
git_only: default_scan_git_only(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HomeViewMode {
#[default]
Normal,
Compact,
Tile,
}
impl HomeViewMode {
pub fn next(self) -> Self {
match self {
HomeViewMode::Normal => HomeViewMode::Compact,
HomeViewMode::Compact => HomeViewMode::Tile,
HomeViewMode::Tile => HomeViewMode::Normal,
}
}
pub fn prev(self) -> Self {
match self {
HomeViewMode::Normal => HomeViewMode::Tile,
HomeViewMode::Compact => HomeViewMode::Normal,
HomeViewMode::Tile => HomeViewMode::Compact,
}
}
pub fn display_name(self) -> &'static str {
match self {
HomeViewMode::Normal => "Normal",
HomeViewMode::Compact => "Compact",
HomeViewMode::Tile => "Tile",
}
}
}
#[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_fetch_timeout_secs")]
pub fetch_timeout_secs: 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)]
pub labels: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
pub repo_configs: std::collections::HashMap<String, RepoConfig>,
#[serde(default)]
pub label_configs: std::collections::HashMap<String, LabelConfig>,
#[serde(default)]
pub sort_reverse: bool,
#[serde(default)]
pub pinned: std::collections::HashSet<String>,
#[serde(default)]
pub starred: std::collections::HashSet<String>,
#[serde(default)]
pub watch_dirs: Vec<String>,
#[serde(skip)]
pub theme: ThemeConfig,
#[serde(rename = "theme", default = "default_theme_name")]
pub theme_name: String,
#[serde(alias = "fzf", rename = "scan", default = "default_scan")]
pub scan: ScanConfig,
#[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_prompt_cwd_repo")]
pub prompt_cwd_repo: bool,
#[serde(default = "default_enable_commit_signatures")]
pub enable_commit_signatures: bool,
#[serde(default = "default_ssh_strict_host_checking")]
pub ssh_strict_host_checking: bool,
#[serde(default = "default_editor")]
pub editor: String,
#[serde(default)]
pub compact_view: bool,
#[serde(default)]
pub view_mode: HomeViewMode,
#[serde(default)]
pub tile_columns: usize,
#[serde(default = "default_show_grouping")]
pub show_grouping: bool,
#[serde(default = "default_auto_fetch_interval_mins")]
pub auto_fetch_interval_mins: u64,
#[serde(default = "default_show_system_stats")]
pub show_system_stats: bool,
#[serde(default = "default_show_separators")]
pub show_separators: bool,
#[serde(default = "default_stale_threshold_months")]
pub stale_threshold_months: u32,
#[serde(default = "default_show_stale_projects")]
pub show_stale_projects: bool,
#[serde(default = "default_enable_watch_dirs")]
pub enable_watch_dirs: 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" => "*",
"rule" => "-",
"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" => "●",
"rule" => "┄",
"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")
}
fn handle_parse_error(path: &Path, _error: Box<dyn Error>) -> (Config, Option<String>) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let corrupt_path = path.with_extension(format!("toml.corrupt-{}", ts));
let rename_result = fs::rename(path, &corrupt_path);
let fallback = Config {
items: vec![],
poll_interval_ms: default_poll_interval_ms(),
fetch_timeout_secs: default_fetch_timeout_secs(),
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(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
label_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: vec![],
theme_name: default_theme_name(),
theme: default_theme(),
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
ssh_strict_host_checking: false,
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
show_separators: default_show_separators(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
prompt_cwd_repo: default_prompt_cwd_repo(),
enable_watch_dirs: default_enable_watch_dirs(),
};
let _ = save_config(&fallback, path);
let msg = match rename_result {
Ok(_) => format!(
"Config corrupt! Moved to {} and reset to defaults",
corrupt_path.file_name().unwrap_or_default().to_string_lossy()
),
Err(e) => format!("Config corrupt! Reset to defaults (failed to rename: {})", e),
};
(fallback, Some(msg))
}
fn load_and_parse_config(path: &Path) -> (Config, Option<AppState>, Option<String>) {
let parsed = fs::read_to_string(path).map_err(|e| e.into()).and_then(|contents| {
toml::from_str::<toml::Table>(&contents).map_err(|e| e.into()).and_then(|mut table| {
let legacy = AppState::take_legacy(&mut table);
toml::Value::Table(table)
.try_into::<Config>()
.map(|config| (config, legacy))
.map_err(|e| e.into())
})
});
match parsed {
Ok((config, legacy)) => {
let (config, warning) = sanitize_config(config);
(config, legacy, warning)
}
Err(err) => {
let (config, warning) = handle_parse_error(path, err);
(config, None, warning)
}
}
}
fn sanitize_config(mut config: Config) -> (Config, Option<String>) {
if config.compact_view && config.view_mode == HomeViewMode::Normal {
config.view_mode = HomeViewMode::Compact;
config.compact_view = false;
}
if config.scan.excludes.is_empty() {
config.scan.excludes = default_scan_excludes();
} else if config.scan.excludes.contains(&"target".to_string())
&& !config.scan.excludes.contains(&"checkout".to_string())
{
config.scan.excludes.push("checkout".to_string());
}
let allowed = ["git", "gitui", "lazygit"];
if !allowed.contains(&config.git_app.as_str()) {
let old_val = config.git_app.clone();
config.git_app = "gitui".to_string();
let warning = format!("Invalid preferred Git client '{}' reset to 'gitui'", old_val);
(config, Some(warning))
} else {
(config, None)
}
}
pub fn load_config(cli_path: Option<PathBuf>) -> Result<LoadedConfig, Box<dyn Error>> {
let (config, legacy, path, warning) = load_config_file(cli_path)?;
let state_path = AppState::path_for(&path);
let (mut state, state_warning) = load_state(&state_path);
if let Some(legacy) = legacy {
state.absorb(legacy);
if save_state(&state, &state_path).is_ok() {
let _ = save_config(&config, &path);
}
}
let warning = match (warning, state_warning) {
(Some(a), Some(b)) => Some(format!("{}; {}", a, b)),
(a, b) => a.or(b),
};
Ok((config, state, path, warning))
}
pub type LoadedConfig = (Config, AppState, PathBuf, Option<String>);
type LoadedConfigFile = (Config, Option<AppState>, PathBuf, Option<String>);
fn load_config_file(cli_path: Option<PathBuf>) -> Result<LoadedConfigFile, Box<dyn Error>> {
if let Some(path) = cli_path {
if path.exists() {
let (mut config, legacy, warning) = load_and_parse_config(&path);
let themes_dir = path.parent().unwrap_or(&path).join("themes");
let _ = fs::create_dir_all(&themes_dir);
let _ = write_popular_themes(&themes_dir);
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
if let Ok(theme_contents) = fs::read_to_string(&theme_path) {
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
} else {
config.theme = default_theme();
}
} else {
config.theme = default_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);
}
if let Ok(theme_serialized) = toml::to_string_pretty(&config.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
}
return Ok((config, legacy, path, warning));
}
let fallback_theme = default_theme();
let fallback_theme_name = default_theme_name();
let themes_dir = path.parent().unwrap_or(&path).join("themes");
let _ = fs::create_dir_all(&themes_dir);
let _ = write_popular_themes(&themes_dir);
let theme_path = themes_dir.join(format!("{}.theme", fallback_theme_name));
if let Ok(theme_serialized) = toml::to_string_pretty(&fallback_theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
return Ok((
Config {
items: vec![],
poll_interval_ms: default_poll_interval_ms(),
fetch_timeout_secs: default_fetch_timeout_secs(),
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(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
label_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: vec![],
theme_name: fallback_theme_name,
theme: fallback_theme,
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
ssh_strict_host_checking: false,
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
show_separators: default_show_separators(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
prompt_cwd_repo: default_prompt_cwd_repo(),
enable_watch_dirs: default_enable_watch_dirs(),
},
None,
path,
None,
));
}
let gitwig_dir = home_gitwig_dir();
let _ = fs::create_dir_all(&gitwig_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&gitwig_dir) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(&gitwig_dir, perms);
}
}
let canonical = gitwig_dir.join("config.toml");
let themes_dir = gitwig_dir.join("themes");
let _ = fs::create_dir_all(&themes_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&themes_dir) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(&themes_dir, perms);
}
}
let _ = write_popular_themes(&themes_dir);
if canonical.exists() {
let (mut config, legacy, warning) = load_and_parse_config(&canonical);
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
if let Ok(theme_contents) = fs::read_to_string(&theme_path) {
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
} else {
config.theme = default_theme();
}
} else {
config.theme = default_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);
}
if let Ok(theme_serialized) = toml::to_string_pretty(&config.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
}
return Ok((config, legacy, canonical, warning));
}
if let Some(source) = find_legacy_config() {
fs::copy(&source, &canonical)?;
let (mut config, legacy, warning) = load_and_parse_config(&canonical);
let theme_path = themes_dir.join(format!("{}.theme", config.theme_name));
if theme_path.exists() {
if let Ok(theme_contents) = fs::read_to_string(&theme_path) {
if let Ok(theme) = toml::from_str::<ThemeConfig>(&theme_contents) {
config.theme = theme;
} else {
config.theme = default_theme();
}
} else {
config.theme = default_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);
}
if let Ok(theme_serialized) = toml::to_string_pretty(&config.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
}
return Ok((config, legacy, canonical, warning));
}
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(),
fetch_timeout_secs: default_fetch_timeout_secs(),
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(),
labels: std::collections::HashMap::new(),
repo_configs: std::collections::HashMap::new(),
label_configs: std::collections::HashMap::new(),
sort_reverse: false,
pinned: std::collections::HashSet::new(),
starred: std::collections::HashSet::new(),
watch_dirs: vec![],
theme_name: default_theme_name(),
theme: default_theme(),
scan: default_scan(),
git_app: default_git_app(),
compatibility_mode: true,
resync_on_tab_change: false,
enable_commit_signatures: false,
ssh_strict_host_checking: false,
editor: default_editor(),
compact_view: false,
view_mode: HomeViewMode::Normal,
tile_columns: 0,
show_grouping: true,
auto_fetch_interval_mins: default_auto_fetch_interval_mins(),
show_system_stats: default_show_system_stats(),
show_separators: default_show_separators(),
stale_threshold_months: default_stale_threshold_months(),
show_stale_projects: default_show_stale_projects(),
prompt_cwd_repo: default_prompt_cwd_repo(),
enable_watch_dirs: default_enable_watch_dirs(),
};
save_config(&fallback, &canonical)?;
let theme_path = themes_dir.join(format!("{}.theme", fallback.theme_name));
if let Ok(theme_serialized) = toml::to_string_pretty(&fallback.theme) {
let _ = fs::write(&theme_path, theme_serialized);
}
Ok((fallback, None, canonical, None))
}
fn write_popular_themes(themes_dir: &Path) -> Result<(), Box<dyn Error>> {
let popular_themes = [
(
"catppuccin",
r#"accent = "lightblue"
warning = "lightyellow"
danger = "lightred"
success = "lightgreen"
border_type = "rounded"
"#,
),
(
"cyberpunk",
r#"accent = "lightmagenta"
warning = "lightyellow"
danger = "lightred"
success = "lightcyan"
border_type = "double"
"#,
),
(
"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"
"#,
),
(
"gitwig",
include_str!("../config/themes/gitwig.theme"),
),
(
"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"
"#,
),
(
"onedark",
r#"accent = "blue"
warning = "yellow"
danger = "lightred"
success = "green"
border_type = "rounded"
"#,
),
(
"rosepine",
r#"accent = "lightmagenta"
warning = "yellow"
danger = "lightred"
success = "lightcyan"
border_type = "rounded"
"#,
),
(
"solarized_dark",
r#"accent = "cyan"
warning = "yellow"
danger = "red"
success = "green"
border_type = "rounded"
"#,
),
(
"tokyonight",
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>> {
let serialized = toml::to_string_pretty(config)?;
write_toml_atomic(path, &serialized)
}
pub(crate) fn write_toml_atomic(path: &Path, serialized: &str) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(parent) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
}
}
static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp_path = path.with_extension(format!(
"toml.tmp-{}-{}",
std::process::id(),
TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
if let Err(e) = fs::write(&tmp_path, serialized) {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&tmp_path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = fs::set_permissions(&tmp_path, perms);
}
}
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(e.into());
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn get_unique_id() -> String {
let count = TEST_DIR_COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let nanos =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
format!("{}_{}_{}", pid, nanos, count)
}
#[test]
fn test_theme_separation_load_and_save() {
let unique_id = get_unique_id();
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 = get_unique_id();
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("catppuccin.theme").exists());
assert!(test_dir.join("cyberpunk.theme").exists());
assert!(test_dir.join("dracula.theme").exists());
assert!(test_dir.join("forest.theme").exists());
assert!(test_dir.join("gitwig.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());
assert!(test_dir.join("onedark.theme").exists());
assert!(test_dir.join("rosepine.theme").exists());
assert!(test_dir.join("solarized_dark.theme").exists());
assert!(test_dir.join("tokyonight.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);
}
#[test]
fn test_corrupt_config_recovery() {
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_corrupt_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
fs::write(&config_path, "items = [").unwrap();
let (config, _, path, warning) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert!(config_path.exists());
assert!(warning.is_some());
assert!(warning.unwrap().contains("Config corrupt!"));
assert!(config.items.is_empty());
let files = fs::read_dir(&test_dir).unwrap();
let corrupt_exists = files
.filter_map(Result::ok)
.any(|entry| entry.file_name().to_string_lossy().contains("config.toml.corrupt-"));
assert!(corrupt_exists);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_git_app_validation_recovery() {
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_git_app_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
let config_toml = r#"
items = []
poll_interval_ms = 1000
max_commits = 100
graph_max_commits = 100
detail_cache_ttl_secs = 60
tab_ttl_secs = 60
page_size = 50
sort_by = "alphabetical"
visits = {}
labels = {}
repo_configs = {}
sort_reverse = false
pinned = []
theme_name = "default"
scan = { excludes = [], max_depth = 5, start_dir = "~" }
git_app = "malicious_binary"
compatibility_mode = true
resync_on_tab_change = false
enable_commit_signatures = false
"#;
fs::write(&config_path, config_toml).unwrap();
let (config, _, path, warning) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert_eq!(config.git_app, "gitui");
assert!(warning.is_some());
assert!(warning.unwrap().contains("Invalid preferred Git client"));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_config_coverage_boost() {
let ssh_val = ssh_command_val();
assert!(!ssh_val.is_empty());
let config = Config { compatibility_mode: true, ..Config::default() };
assert_eq!(config.sym("branch"), "* ");
let config2 = Config { compatibility_mode: false, ..Config::default() };
assert_eq!(config2.sym("branch"), "\u{e0a0} ");
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_parse_err_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let err_path = test_dir.join("config.toml");
let (config, warning) =
handle_parse_error(&err_path, Box::new(std::io::Error::other("mock error")));
assert!(warning.is_some());
assert!(config.items.is_empty());
let _ = fs::remove_dir_all(&test_dir);
let _legacy = find_legacy_config();
}
#[test]
fn test_state_keys_migrate_out_of_config_toml() {
let unique_id = get_unique_id();
let test_dir =
std::env::temp_dir().join(format!("gitwig_test_state_migrate_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
let state_path = AppState::path_for(&config_path);
let legacy_config = r#"
items = ["/a", "/b"]
sort_by = "recent_visit"
active_label_filter = "work"
label_slots = ["work", "oss"]
[visits]
"/a" = 10
"/b" = 20
[labels]
"/a" = ["work"]
[repo_configs."/a"]
page_size = 5
commit_history = ["second", "first"]
"#;
fs::write(&config_path, legacy_config).unwrap();
assert!(!state_path.exists());
let (config, state, path, warning) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(path, config_path);
assert!(warning.is_none());
assert_eq!(config.items, vec!["/a".to_string(), "/b".to_string()]);
assert_eq!(config.sort_by, SortOrder::RecentVisit);
assert_eq!(config.repo_configs["/a"].page_size, Some(5));
assert_eq!(config.labels["/a"], vec!["work".to_string()]);
assert_eq!(state.visits.get("/a"), Some(&10));
assert_eq!(state.visits.get("/b"), Some(&20));
assert_eq!(state.active_label_filter.as_deref(), Some("work"));
assert_eq!(state.label_slots, vec!["work".to_string(), "oss".to_string()]);
assert_eq!(state.commit_history_for("/a"), vec!["second".to_string(), "first".to_string()]);
assert!(state_path.exists());
let rewritten = fs::read_to_string(&config_path).unwrap();
for key in ["visits", "active_label_filter", "label_slots", "commit_history"] {
assert!(!rewritten.contains(key), "`{}` still in config.toml:\n{}", key, rewritten);
}
assert!(rewritten.contains("page_size = 5"), "rewritten: {}", rewritten);
let (from_disk, _) = load_state(&state_path);
assert_eq!(from_disk, state);
let (_, state_again, _, warning) = load_config(Some(config_path.clone())).unwrap();
assert!(warning.is_none());
assert_eq!(state_again, state);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_state_toml_wins_over_stale_keys_left_in_config_toml() {
let unique_id = get_unique_id();
let test_dir = std::env::temp_dir().join(format!("gitwig_test_state_merge_{}", unique_id));
fs::create_dir_all(&test_dir).unwrap();
let config_path = test_dir.join("config.toml");
let state_path = AppState::path_for(&config_path);
fs::write(
&config_path,
"items = [\"/a\"]\nactive_label_filter = \"old\"\n[visits]\n\"/a\" = 1\n\"/b\" = 2\n",
)
.unwrap();
let mut existing = AppState::default();
existing.visits.insert("/a".to_string(), 99);
existing.active_label_filter = Some("new".to_string());
save_state(&existing, &state_path).unwrap();
let (_, state, _, _) = load_config(Some(config_path.clone())).unwrap();
assert_eq!(state.visits.get("/a"), Some(&99), "state.toml value kept");
assert_eq!(state.visits.get("/b"), Some(&2), "missing entry filled from config");
assert_eq!(state.active_label_filter.as_deref(), Some("new"));
let _ = fs::remove_dir_all(&test_dir);
}
}