use color_eyre::{Result, eyre::eyre};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::theme::{LoadThemeError, Theme, load_theme};
mod defaults;
mod load;
mod terminal;
#[cfg(test)]
mod tests;
use defaults::*;
use load::{LoadResolution, default_write_path, resolve_load};
use terminal::default_goto_command;
pub(crate) use load::{ConfigEnv, RealEnv, candidate_theme_dirs, worktree_path};
const APP_NAME: &str = "gitpane";
const CONFIG_FILE: &str = "config.toml";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct Config {
#[serde(default = "default_root_dirs")]
pub root_dirs: Vec<PathBuf>,
#[serde(default)]
pub excluded_repos: Vec<String>,
#[serde(default)]
pub pinned_repos: Vec<PathBuf>,
#[serde(default = "default_scan_depth")]
pub scan_depth: usize,
#[serde(default)]
pub watch: WatchConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub graph: GraphConfig,
#[serde(default)]
pub submodules: SubmoduleConfig,
#[serde(default)]
pub open: OpenConfig,
#[serde(default)]
pub review: ReviewConfig,
#[serde(default)]
pub worktree: WorktreeConfig,
#[serde(default)]
pub goto: GotoConfig,
#[serde(default = "default_theme_name", rename = "theme")]
pub theme_name: String,
#[serde(skip, default)]
pub theme: Theme,
#[serde(skip, default)]
pub runtime_theme_override: Option<String>,
#[serde(skip, default)]
pub(crate) loaded_path: Option<PathBuf>,
#[serde(skip, default)]
pub(crate) write_target_override: Option<PathBuf>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct WatchConfig {
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u64,
#[serde(default = "default_refresh_cooldown_ms")]
pub refresh_cooldown_ms: u64,
#[serde(default = "default_watch_worktree_dirs")]
pub watch_worktree_dirs: bool,
#[serde(default = "default_poll_local_secs")]
pub poll_local_secs: u64,
#[serde(default = "default_poll_fetch_secs")]
pub poll_fetch_secs: u64,
#[serde(default = "default_max_concurrent_polls")]
pub max_concurrent_polls: usize,
#[serde(default = "default_watch_exclude_dirs")]
pub watch_exclude_dirs: Vec<String>,
#[serde(default = "default_discovery_cooldown_secs")]
pub discovery_cooldown_secs: u64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum UpdatePosition {
#[default]
TopRight,
TopLeft,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct UiConfig {
#[serde(default = "default_frame_rate")]
pub frame_rate: u16,
#[serde(default = "default_check_for_updates")]
pub check_for_updates: bool,
#[serde(default)]
pub update_position: UpdatePosition,
#[serde(default = "default_show_liveness")]
pub show_liveness: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum BranchFilter {
#[default]
All,
Local,
Remote,
None,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct GraphConfig {
#[serde(default)]
pub branches: BranchFilter,
#[serde(default = "default_label_max_len")]
pub label_max_len: usize,
#[serde(default = "default_show_stats")]
pub show_stats: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct SubmoduleConfig {
#[serde(default)]
pub ignore_dirty: bool,
#[serde(default = "default_warn_unpushed")]
pub warn_unpushed: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct OpenConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default = "default_open_placement")]
pub placement: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ReviewConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default = "default_review_placement")]
pub placement: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct GotoConfig {
#[serde(default = "default_goto_command")]
pub command: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct WorktreeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<PathBuf>,
}
impl Config {
pub fn load() -> Result<Self> {
Self::load_with_env(&RealEnv)
}
#[allow(dead_code)]
pub fn config_path() -> PathBuf {
default_write_path(&RealEnv).unwrap_or_else(|| PathBuf::from("config.toml"))
}
pub fn save(&self) -> Result<()> {
self.save_with_env(&RealEnv)
}
pub(crate) fn load_with_env(env: &dyn ConfigEnv) -> Result<Self> {
let mut config = match resolve_load(env) {
LoadResolution::EnvOverride(path) => {
let exists = env.file_exists(&path);
let mut config = if exists {
let contents = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&contents)?;
config.expand_tildes();
tracing::info!(path = %path.display(), "loaded config (GITPANE_CONFIG)");
config
} else {
tracing::info!(
path = %path.display(),
"GITPANE_CONFIG points to missing file, using defaults"
);
Config::default()
};
config.loaded_path = exists.then(|| path.clone());
config.write_target_override = Some(path);
config
}
LoadResolution::SearchOrder(paths) => {
let mut loaded = None;
for path in &paths {
if env.file_exists(path) {
let contents = std::fs::read_to_string(path)?;
let mut config: Config = toml::from_str(&contents)?;
config.expand_tildes();
config.loaded_path = Some(path.clone());
tracing::info!(path = %path.display(), "loaded config");
loaded = Some(config);
break;
}
}
loaded.unwrap_or_else(|| {
tracing::info!(candidates = ?paths, "no config file found, using defaults");
Config::default()
})
}
};
config.resolve_theme(env);
Ok(config)
}
pub(crate) fn resolve_theme_with_env(&mut self, env: &dyn ConfigEnv) {
self.resolve_theme(env);
}
pub fn effective_theme_name(&self) -> &str {
self.runtime_theme_override
.as_deref()
.unwrap_or(&self.theme_name)
}
pub(crate) fn theme_dirs(&self, env: &dyn ConfigEnv) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for source in [
self.loaded_path.as_deref(),
self.write_target_override.as_deref(),
]
.into_iter()
.flatten()
{
if let Some(parent) = source.parent()
&& !parent.as_os_str().is_empty()
{
let parent = parent.to_path_buf();
if !dirs.contains(&parent) {
dirs.push(parent);
}
}
}
for dir in candidate_theme_dirs(env) {
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
dirs
}
fn resolve_theme(&mut self, env: &dyn ConfigEnv) {
let name = self.effective_theme_name().to_string();
let dirs = self.theme_dirs(env);
match load_theme(&name, &dirs) {
Ok(theme) => self.theme = theme,
Err(e @ LoadThemeError::Unknown { .. }) => {
tracing::warn!("{e}; falling back to default theme");
self.theme = Theme::default();
}
Err(e @ LoadThemeError::InvalidFile { .. }) => {
tracing::warn!("{e}; falling back to default theme");
self.theme = Theme::default();
}
}
}
pub(crate) fn save_with_env(&self, env: &dyn ConfigEnv) -> Result<()> {
let config_path = self
.write_target_override
.clone()
.or_else(|| self.loaded_path.clone())
.or_else(|| default_write_path(env))
.ok_or_else(|| eyre!("no writable config path available"))?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
pub fn add_pinned_repo(&mut self, path: PathBuf) {
if !self.pinned_repos.contains(&path) {
self.pinned_repos.push(path);
}
}
pub fn override_root(&mut self, root: PathBuf) {
self.root_dirs = vec![root];
}
fn expand_tildes(&mut self) {
if let Some(home) = dirs::home_dir() {
for dir in &mut self.root_dirs {
if dir.starts_with("~") {
*dir = home.join(dir.strip_prefix("~").unwrap());
}
}
for dir in &mut self.pinned_repos {
if dir.starts_with("~") {
*dir = home.join(dir.strip_prefix("~").unwrap());
}
}
if let Some(dir) = &mut self.worktree.dir
&& dir.starts_with("~")
{
*dir = home.join(dir.strip_prefix("~").unwrap());
}
}
}
}