use anyhow::{Context, Result};
use directories::ProjectDirs;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub ui: UiConfig,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AuthConfig {
pub api_key: Option<String>,
pub oauth_client_id: Option<String>,
pub oauth_client_secret: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UiConfig {
pub default_team: Option<String>,
#[serde(default = "default_items_per_page")]
pub items_per_page: u32,
#[serde(default)]
pub theme: ThemeName,
#[serde(default = "default_true")]
pub sidebar: bool,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: u16,
#[serde(default)]
pub group_by: GroupByName,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
default_team: None,
items_per_page: default_items_per_page(),
theme: ThemeName::default(),
sidebar: default_true(),
sidebar_width: default_sidebar_width(),
group_by: GroupByName::default(),
}
}
}
fn default_items_per_page() -> u32 {
50
}
fn default_true() -> bool {
true
}
fn default_sidebar_width() -> u16 {
26
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GroupByName {
#[default]
Status,
Assignee,
Priority,
Project,
None,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeName {
#[default]
Default,
Light,
Ocean,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub accent: Color,
pub secondary: Color,
pub muted: Color,
pub warning: Color,
pub error: Color,
pub success: Color,
pub text: Color,
pub text_dim: Color,
pub border: Color,
pub highlight_fg: Color,
pub selection_bg: Color,
pub surface: Color,
pub code_bg: Color,
pub chip_bg: Color,
pub pri_urgent: Color,
pub pri_high: Color,
pub pri_medium: Color,
pub pri_low: Color,
}
impl Theme {
pub fn from_name(name: ThemeName) -> Self {
match name {
ThemeName::Default => Self::dark(),
ThemeName::Light => Self::light(),
ThemeName::Ocean => Self::ocean(),
}
}
fn dark() -> Self {
Self {
accent: Color::Rgb(126, 138, 240),
secondary: Color::Rgb(180, 140, 255),
muted: Color::Rgb(110, 114, 126),
warning: Color::Yellow,
error: Color::Red,
success: Color::Green,
text: Color::White,
text_dim: Color::Gray,
border: Color::Rgb(60, 62, 70),
highlight_fg: Color::White,
selection_bg: Color::Rgb(40, 42, 54),
surface: Color::Rgb(28, 28, 32),
code_bg: Color::Rgb(38, 38, 44),
chip_bg: Color::Rgb(45, 45, 52),
pri_urgent: Color::Red,
pri_high: Color::Rgb(255, 165, 0),
pri_medium: Color::Yellow,
pri_low: Color::Blue,
}
}
fn light() -> Self {
Self {
accent: Color::Blue,
secondary: Color::Magenta,
muted: Color::Gray,
warning: Color::Rgb(200, 150, 0),
error: Color::Red,
success: Color::Green,
text: Color::Black,
text_dim: Color::DarkGray,
border: Color::Rgb(205, 205, 210),
highlight_fg: Color::Black,
selection_bg: Color::Rgb(225, 228, 240),
surface: Color::Rgb(240, 240, 244),
code_bg: Color::Rgb(233, 233, 238),
chip_bg: Color::Rgb(228, 228, 234),
pri_urgent: Color::Red,
pri_high: Color::Rgb(200, 100, 0),
pri_medium: Color::Rgb(180, 150, 0),
pri_low: Color::Blue,
}
}
fn ocean() -> Self {
Self {
accent: Color::Rgb(100, 200, 255),
secondary: Color::Rgb(180, 140, 255),
muted: Color::Rgb(80, 80, 100),
warning: Color::Rgb(255, 200, 80),
error: Color::Rgb(255, 100, 100),
success: Color::Rgb(100, 220, 150),
text: Color::Rgb(220, 230, 240),
text_dim: Color::Rgb(140, 150, 170),
border: Color::Rgb(55, 72, 96),
highlight_fg: Color::Rgb(240, 245, 255),
selection_bg: Color::Rgb(30, 48, 70),
surface: Color::Rgb(21, 32, 46),
code_bg: Color::Rgb(28, 42, 60),
chip_bg: Color::Rgb(34, 50, 70),
pri_urgent: Color::Rgb(255, 80, 80),
pri_high: Color::Rgb(255, 165, 80),
pri_medium: Color::Rgb(255, 220, 80),
pri_low: Color::Rgb(80, 160, 255),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if path.exists() {
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
toml::from_str(&contents).with_context(|| "Failed to parse config")
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
fs::write(&path, contents)?;
Ok(())
}
pub fn config_dir() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "linear-tui")
.context("Failed to determine config directory")?;
Ok(dirs.config_dir().to_path_buf())
}
fn config_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join("config.toml"))
}
}