Add Ratatui terminal application

This commit is contained in:
Georg Bauer
2026-08-03 21:15:16 +02:00
parent 976742e173
commit 2534010d1d
13 changed files with 4414 additions and 375 deletions

512
crates/gitea/src/config.rs Normal file
View File

@@ -0,0 +1,512 @@
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
io::Write,
path::PathBuf,
process::{self, Command},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use serde::{Deserialize, Serialize};
use crate::{Client, Provider, RepositoryId, Url};
type Result<T> = std::result::Result<T, String>;
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct ServerProfile {
pub url: String,
pub token: String,
#[serde(default)]
pub provider: Provider,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct TuiPreferences {
#[serde(default = "default_refresh_seconds")]
pub refresh_seconds: u64,
#[serde(default)]
pub favorites: BTreeSet<String>,
}
impl Default for TuiPreferences {
fn default() -> Self {
Self {
refresh_seconds: default_refresh_seconds(),
favorites: BTreeSet::new(),
}
}
}
const fn default_refresh_seconds() -> u64 {
5
}
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Config {
#[serde(skip)]
path: PathBuf,
#[serde(default)]
pub servers: BTreeMap<String, ServerProfile>,
#[serde(default)]
pub tui: TuiPreferences,
}
pub struct Selection {
pub name: Option<String>,
pub url: String,
pub token: Option<String>,
pub provider: Provider,
pub repository: Option<RepositoryId>,
}
impl Config {
pub fn load() -> Result<Self> {
let home = env::var_os("HOME").ok_or("HOME is not set")?;
Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
}
fn load_from(path: PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self {
path,
..Self::default()
});
}
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let mut config: Self = serde_yaml::from_str(&text)
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
config.path = path;
for (name, server) in &config.servers {
validate_name(name)?;
Client::with_provider(&server.url, Some(&server.token), server.provider)
.map_err(|error| format!("invalid server {name}: {error}"))?;
}
Ok(config)
}
pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> {
let url = server_url(name)?;
self.save_server(name, &url, token, provider)
}
pub fn save_server(
&mut self,
name: &str,
url: &str,
token: &str,
provider: Provider,
) -> Result<()> {
validate_name(name)?;
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
let mut updated = self.clone();
updated.servers.insert(
name.into(),
ServerProfile {
url: url.trim_end_matches('/').into(),
token: token.into(),
provider,
},
);
updated.save()?;
*self = updated;
Ok(())
}
pub fn replace_server(
&mut self,
original_name: &str,
name: &str,
url: &str,
token: &str,
provider: Provider,
) -> Result<()> {
if !self.servers.contains_key(original_name) {
return Err(format!("server profile {original_name:?} does not exist"));
}
validate_name(name)?;
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
let mut updated = self.clone();
let old_url = updated
.servers
.get(original_name)
.map(|server| server.url.clone())
.expect("profile existence checked above");
let new_url = url.trim_end_matches('/');
updated.servers.remove(original_name);
updated.servers.insert(
name.into(),
ServerProfile {
url: new_url.into(),
token: token.into(),
provider,
},
);
if old_url != new_url {
migrate_favorites(&mut updated.tui.favorites, &old_url, new_url);
}
updated.save()?;
*self = updated;
Ok(())
}
pub fn logout(&mut self, name: &str) -> Result<()> {
if !self.servers.contains_key(name) {
return Err(format!("server profile {name:?} does not exist"));
}
let mut updated = self.clone();
let removed_url = updated.servers[name].url.clone();
updated.servers.remove(name);
if !updated
.servers
.values()
.any(|server| server.url == removed_url)
{
remove_favorites(&mut updated.tui.favorites, &removed_url);
}
updated.save()?;
*self = updated;
Ok(())
}
pub fn set_tui_refresh_seconds(&mut self, seconds: u64) -> Result<()> {
let mut updated = self.clone();
updated.tui.refresh_seconds = seconds;
updated.save()?;
*self = updated;
Ok(())
}
pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool {
self.tui
.favorites
.contains(&favorite_key(pane, server_url, repository))
}
pub fn toggle_tui_favorite(
&mut self,
pane: &str,
server_url: &str,
repository: &RepositoryId,
) -> Result<()> {
let key = favorite_key(pane, server_url, repository);
let mut updated = self.clone();
if !updated.tui.favorites.remove(&key) {
updated.tui.favorites.insert(key);
}
updated.save()?;
*self = updated;
Ok(())
}
pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
if name.is_some() && url.is_some() {
return Err("use either --server or --url, not both".into());
}
let remotes = git_remotes().unwrap_or_default();
if let Some(name) = name {
let server = self
.servers
.get(name)
.ok_or_else(|| format!("server profile {name:?} does not exist"))?;
return Ok(selection(Some(name), server, &remotes));
}
if let Some(url) = url {
let matches: Vec<_> = self
.servers
.iter()
.filter(|(_, server)| same_instance(&server.url, url))
.collect();
return match matches.as_slice() {
[] => Ok(selection(
None,
&ServerProfile {
url: url.into(),
token: String::new(),
provider: Provider::Gitea,
},
&remotes,
)),
[(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
_ => Err("multiple profiles use that URL; select one with --server".into()),
};
}
let mut matches = BTreeMap::new();
for (name, server) in &self.servers {
if let Some(scope) = remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote))
{
matches.insert(name, (server, scope));
}
}
match matches.into_iter().collect::<Vec<_>>().as_slice() {
[] if self.servers.is_empty() => {
Err("no servers configured; run `gotcha auth login SERVER`".into())
}
[] => Err("no configured server matches this Git repository; use --server NAME".into()),
[(name, (server, scope))] => Ok(Selection {
name: Some((*name).clone()),
url: server.url.clone(),
token: Some(server.token.clone()),
provider: server.provider,
repository: Some(scope.clone()),
}),
_ => Err("multiple server profiles match this Git repository; use --server".into()),
}
}
fn save(&self) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
let result = (|| -> std::io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temporary)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
fs::rename(&temporary, &self.path)?;
#[cfg(unix)]
fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
}
}
fn favorite_key(pane: &str, server_url: &str, repository: &RepositoryId) -> String {
format!(
"{pane}|{}|{}/{}",
server_url.trim_end_matches('/'),
repository.owner,
repository.repository
)
}
fn migrate_favorites(favorites: &mut BTreeSet<String>, old_url: &str, new_url: &str) {
let old = format!("|{}|", old_url.trim_end_matches('/'));
let new = format!("|{}|", new_url.trim_end_matches('/'));
let migrations: Vec<_> = favorites
.iter()
.filter(|key| key.contains(&old))
.map(|key| (key.clone(), key.replacen(&old, &new, 1)))
.collect();
for (old, new) in migrations {
favorites.remove(&old);
favorites.insert(new);
}
}
fn remove_favorites(favorites: &mut BTreeSet<String>, server_url: &str) {
let server = format!("|{}|", server_url.trim_end_matches('/'));
favorites.retain(|key| !key.contains(&server));
}
fn selection(name: Option<&str>, server: &ServerProfile, remotes: &[String]) -> Selection {
Selection {
name: name.map(str::to_owned),
url: server.url.clone(),
token: (!server.token.is_empty()).then(|| server.token.clone()),
provider: server.provider,
repository: remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote)),
}
}
fn validate_name(name: &str) -> Result<()> {
server_url(name)?;
Ok(())
}
pub fn server_url(name: &str) -> Result<String> {
if name.is_empty()
|| name.contains('/')
|| name.contains('@')
|| name.chars().any(char::is_whitespace)
{
return Err("server name must be a hostname, optionally followed by a port".into());
}
let url = format!("https://{name}");
let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
if parsed.host_str().is_none() || parsed.path() != "/" {
return Err("server name must be a hostname, optionally followed by a port".into());
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err("server name must be a hostname, optionally followed by a port".into());
}
Ok(url)
}
fn same_instance(left: &str, right: &str) -> bool {
let api_url = |url| {
Client::new(url, None)
.ok()
.map(|client| client.api_url().clone())
};
api_url(left) == api_url(right)
}
fn git_remotes() -> Result<Vec<String>> {
let output = Command::new("git")
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
.output()
.map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(char::is_whitespace))
.map(|(_, url)| url.trim().to_owned())
.collect())
}
fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryId> {
let server = Url::parse(server_url).ok()?;
let server_host = server.host_str()?;
let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
(
url.host_str()?.to_owned(),
url.path().trim_matches('/').to_owned(),
matches!(url.scheme(), "http" | "https"),
)
} else {
let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
let (host, path) = remote.split_once(':')?;
(host.into(), path.trim_matches('/').into(), false)
};
if !server_host.eq_ignore_ascii_case(&remote_host) {
return None;
}
let prefix = server.path().trim_matches('/');
if is_http && !prefix.is_empty() {
remote_path = remote_path
.strip_prefix(prefix)?
.strip_prefix('/')?
.to_owned();
}
let parts: Vec<_> = remote_path
.split('/')
.filter(|part| !part.is_empty())
.collect();
let [.., owner, repository] = parts.as_slice() else {
return None;
};
let repository = repository.strip_suffix(".git").unwrap_or(repository);
(!owner.is_empty() && !repository.is_empty()).then(|| RepositoryId {
owner: (*owner).into(),
repository: repository.into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trip_and_remote_scope() {
let directory = env::temp_dir().join(format!(
"gotcha-config-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = directory.join("config");
let mut config = Config {
path: path.clone(),
..Config::default()
};
assert_eq!(config.tui.refresh_seconds, 5);
assert_eq!(
config.select(None, None).err().unwrap(),
"no servers configured; run `gotcha auth login SERVER`"
);
config
.login("code.example", "secret", Provider::Forgejo)
.unwrap();
config.set_tui_refresh_seconds(9).unwrap();
let mut loaded = Config::load_from(path).unwrap();
assert_eq!(loaded.tui.refresh_seconds, 9);
assert_eq!(loaded.servers["code.example"].token, "secret");
assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
assert_eq!(
loaded.select(Some("code.example"), None).unwrap().provider,
Provider::Forgejo
);
let repository = RepositoryId {
owner: "alice".into(),
repository: "project".into(),
};
loaded
.toggle_tui_favorite("issues", "https://code.example", &repository)
.unwrap();
assert!(loaded.is_tui_favorite("issues", "https://code.example", &repository));
loaded
.replace_server(
"code.example",
"new.example",
"https://new.example",
"new-secret",
Provider::Gitea,
)
.unwrap();
assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository));
assert!(!loaded.servers.contains_key("code.example"));
assert_eq!(loaded.servers["new.example"].token, "new-secret");
assert!(
fs::read_to_string(&loaded.path)
.unwrap()
.contains("provider: gitea")
);
let legacy: ServerProfile =
serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap();
assert_eq!(legacy.provider, Provider::Gitea);
let scope = repository_scope(
"https://code.example/gitea",
"https://code.example/gitea/alice/project.git",
)
.unwrap();
assert_eq!(
(scope.owner.as_str(), scope.repository.as_str()),
("alice", "project")
);
#[cfg(unix)]
assert_eq!(
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
0o600
);
fs::remove_dir_all(directory).unwrap();
}
}

View File

@@ -12,6 +12,7 @@ pub use gitea_openapi::models;
pub use models::{Repository, ServerVersion, User};
pub mod activity;
mod config;
pub mod diff;
mod domain;
mod issues;
@@ -20,6 +21,7 @@ mod pulls;
mod repositories;
pub use activity::ActivityFilter;
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,