Add Ratatui terminal application
This commit is contained in:
1286
Cargo.lock
generated
1286
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/gitea", "crates/cli", "crates/app"]
|
members = ["crates/gitea", "crates/cli", "crates/app", "crates/tui"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Gotcha
|
# Gotcha
|
||||||
|
|
||||||
Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
|
Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
|
||||||
CLI, and a native iOS application. The iOS interface is UIKit/Swift; UniFFI
|
CLI, a Ratatui terminal interface, and a native iOS application. The iOS
|
||||||
exposes the Rust application logic to Swift.
|
interface is UIKit/Swift; UniFFI exposes the Rust application logic to Swift.
|
||||||
|
|
||||||
On the icon: git in a tea cup. it is obvious, isn't it?
|
On the icon: git in a tea cup. it is obvious, isn't it?
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ not necessarily the app to use when you manage or frequent a large instance.
|
|||||||
- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
|
- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
|
||||||
complete typed Gitea 1.25-compatible API and model surface
|
complete typed Gitea 1.25-compatible API and model surface
|
||||||
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
- `gotcha`: CLI for typed common operations and arbitrary API requests
|
||||||
|
- `gotcha-tui`: paned, keyboard-and-mouse terminal application
|
||||||
- `gotcha-app`: Rust application core and UniFFI API used by iOS
|
- `gotcha-app`: Rust application core and UniFFI API used by iOS
|
||||||
- `ios`: native UIKit application for activity, repositories, favorites,
|
- `ios`: native UIKit application for activity, repositories, favorites,
|
||||||
issues, milestones, pull requests, commit history, changed files, and diffs; bundle
|
issues, milestones, pull requests, commit history, changed files, and diffs; bundle
|
||||||
@@ -88,12 +89,35 @@ derives the `owner/repository` scope. Use `--server gitea.example.com` when sele
|
|||||||
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
|
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
|
||||||
accepted as command-line arguments or environment variables.
|
accepted as command-line arguments or environment variables.
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
Build and run the standalone TUI with an existing CLI server profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p gotcha-tui
|
||||||
|
cargo run -p gotcha-tui -- --server gitea.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The five numbered panes mirror the iPhone app: Home, Issues, Repositories,
|
||||||
|
pull requests, and Milestones. Use `j`/`k` or the arrow keys to select rows,
|
||||||
|
Enter to open, Backspace to return, `/` for list filters, and `a`, `e`, `c`,
|
||||||
|
`x`, and `d` for mutations. Use `n`/`p` for API result pages; scrolling the
|
||||||
|
mouse wheel past a page boundary does the same. Repository lists support `*` favorites, commit
|
||||||
|
lists support `b` branch switching, and Home supports `v` activity filters.
|
||||||
|
Editors use Tab between fields and Ctrl-S to save. Mouse selection,
|
||||||
|
double-click, and wheel scrolling work in ordinary terminals and Herdr.
|
||||||
|
|
||||||
|
Overview panes refresh every five seconds by default. Press `,` to change the
|
||||||
|
interval or set it to zero. Refresh waits while an editor or confirmation is
|
||||||
|
open and after keyboard or mouse activity so it does not move the current
|
||||||
|
selection during interaction.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
`gotcha_gitea` owns Gitea/Forgejo access, authentication, validation,
|
`gotcha_gitea` owns Gitea/Forgejo access, authentication, validation,
|
||||||
mutations, and relationships between server objects. The CLI is a terminal presentation layer:
|
mutations, relationships between server objects, and shared CLI/TUI server
|
||||||
it parses arguments and YAML, invokes shared client operations, and formats the
|
configuration. The CLI and TUI are terminal presentation layers that invoke
|
||||||
results. `gotcha-app` owns application state, preferences, favorites,
|
shared client operations. `gotcha-app` owns application state, preferences, favorites,
|
||||||
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
|
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
|
||||||
navigation, controls, layout, and other platform presentation behavior.
|
navigation, controls, layout, and other platform presentation behavior.
|
||||||
|
|
||||||
|
|||||||
49
TESTING.md
49
TESTING.md
@@ -1,11 +1,11 @@
|
|||||||
# Testing Gotcha
|
# Testing Gotcha
|
||||||
|
|
||||||
This document is the source of truth for iOS visual verification. Every UI
|
This document is the source of truth for iOS and terminal UI verification.
|
||||||
change must update its relevant scenarios in the same commit. For ordinary
|
Every UI change must update its relevant scenarios in the same commit. For
|
||||||
commits, run the build gates and every scenario affected by the change. A
|
ordinary commits, run the build gates and every scenario affected by the
|
||||||
release test means completing this entire checklist successfully on the stated
|
change. A release test means completing this entire checklist successfully on
|
||||||
simulators and devices; record failures as open issues and do not sign off until
|
the stated terminals, simulators, and devices; record failures as open issues
|
||||||
every scenario passes.
|
and do not sign off until every scenario passes.
|
||||||
|
|
||||||
## Test record
|
## Test record
|
||||||
|
|
||||||
@@ -38,6 +38,43 @@ cargo clippy --workspace --all-targets -- -D warnings
|
|||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Terminal UI
|
||||||
|
|
||||||
|
Run `cargo run -p gotcha-tui -- --server NAME` in a normal terminal and in a
|
||||||
|
Herdr pane. Use a non-production account with at least two server profiles and
|
||||||
|
representative repositories.
|
||||||
|
|
||||||
|
- [ ] The five numbered panes open Home, Issues, Repos, PRs, and Milestones;
|
||||||
|
the selected pane and row remain visibly distinct at narrow and wide
|
||||||
|
terminal sizes.
|
||||||
|
- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, Backspace, and `q` provide
|
||||||
|
mutt-style list and history navigation; `n`/`p` and scrolling past a
|
||||||
|
boundary traverse paginated API results without losing history.
|
||||||
|
- [ ] Clicking a pane switches it, clicking a list row selects it,
|
||||||
|
double-clicking opens it, and the mouse wheel scrolls the list.
|
||||||
|
- [ ] Home activity filters cycle with `v`; activity targets route to their
|
||||||
|
repository, issue, pull request, or commit.
|
||||||
|
- [ ] Repository favorites toggle with `*`, sort first per pane, and persist
|
||||||
|
after relaunch. Commits switch branches with `b`; file trees, text and
|
||||||
|
binary files, commit changes, pull changes, and per-file diffs open.
|
||||||
|
- [ ] Issue and pull filters apply. Issues can be created, edited,
|
||||||
|
closed/reopened, and deleted after confirmation; issue comments can be
|
||||||
|
added and the current user's comments edited.
|
||||||
|
- [ ] Milestones can be created, edited, closed/reopened, and deleted after
|
||||||
|
confirmation; milestone details route to their issues and pulls.
|
||||||
|
- [ ] Server profiles can be added, authenticated, selected, edited, renamed,
|
||||||
|
and deleted. Blank token on edit preserves the existing token; removing
|
||||||
|
the last server leaves the server manager available.
|
||||||
|
- [ ] Editors move between fields with Tab/Shift-Tab, support cursor movement,
|
||||||
|
Unicode insertion, Delete, and Backspace without navigating back, mask
|
||||||
|
tokens, save with Ctrl-S, and cancel with Esc.
|
||||||
|
- [ ] The default overview refresh interval is five seconds. Changing it with
|
||||||
|
`,` persists after relaunch; refresh preserves selection and pauses while
|
||||||
|
editing, confirming, scrolling, or otherwise interacting.
|
||||||
|
- [ ] `cargo build --release -p gotcha-tui` produces
|
||||||
|
`target/release/gotcha-tui`, and the installed executable starts from
|
||||||
|
`PATH`.
|
||||||
|
|
||||||
Build and install the simulator app:
|
Build and install the simulator app:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -1,346 +1 @@
|
|||||||
use std::{
|
pub use gotcha_gitea::{Config, RepositoryId as RepositoryScope, Selection, server_url};
|
||||||
collections::BTreeMap,
|
|
||||||
env, fs,
|
|
||||||
io::Write,
|
|
||||||
path::PathBuf,
|
|
||||||
process::{self, Command},
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
||||||
|
|
||||||
use gotcha_gitea::{Client, Provider, RepositoryId, Url};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
type Result<T> = std::result::Result<T, String>;
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
|
||||||
pub struct Server {
|
|
||||||
pub url: String,
|
|
||||||
pub token: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub provider: Provider,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Deserialize, Serialize)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(skip)]
|
|
||||||
path: PathBuf,
|
|
||||||
#[serde(default)]
|
|
||||||
pub servers: BTreeMap<String, Server>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Selection {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub url: String,
|
|
||||||
pub token: Option<String>,
|
|
||||||
pub provider: Provider,
|
|
||||||
pub repository: Option<RepositoryScope>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type RepositoryScope = 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)?;
|
|
||||||
Client::with_provider(&url, Some(token), provider).map_err(|error| error.to_string())?;
|
|
||||||
self.servers.insert(
|
|
||||||
name.into(),
|
|
||||||
Server {
|
|
||||||
url,
|
|
||||||
token: token.into(),
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn logout(&mut self, name: &str) -> Result<()> {
|
|
||||||
if self.servers.remove(name).is_none() {
|
|
||||||
return Err(format!("server profile {name:?} does not exist"));
|
|
||||||
}
|
|
||||||
self.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
&Server {
|
|
||||||
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 selection(name: Option<&str>, server: &Server, 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<RepositoryScope> {
|
|
||||||
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(|| RepositoryScope {
|
|
||||||
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.select(None, None).err().unwrap(),
|
|
||||||
"no servers configured; run `gotcha auth login SERVER`"
|
|
||||||
);
|
|
||||||
config
|
|
||||||
.login("code.example", "secret", Provider::Forgejo)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let loaded = Config::load_from(path).unwrap();
|
|
||||||
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
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
fs::read_to_string(&loaded.path)
|
|
||||||
.unwrap()
|
|
||||||
.contains("provider: forgejo")
|
|
||||||
);
|
|
||||||
let legacy: Server =
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-feature
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
serde_yaml.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
512
crates/gitea/src/config.rs
Normal file
512
crates/gitea/src/config.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub use gitea_openapi::models;
|
|||||||
pub use models::{Repository, ServerVersion, User};
|
pub use models::{Repository, ServerVersion, User};
|
||||||
|
|
||||||
pub mod activity;
|
pub mod activity;
|
||||||
|
mod config;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod issues;
|
mod issues;
|
||||||
@@ -20,6 +21,7 @@ mod pulls;
|
|||||||
mod repositories;
|
mod repositories;
|
||||||
|
|
||||||
pub use activity::ActivityFilter;
|
pub use activity::ActivityFilter;
|
||||||
|
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
|
||||||
pub use domain::{
|
pub use domain::{
|
||||||
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
||||||
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
|
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
|
||||||
|
|||||||
17
crates/tui/Cargo.toml
Normal file
17
crates/tui/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "gotcha-tui"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Ratatui terminal client for Gitea and Forgejo"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gotcha-tui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
crossterm = "0.29"
|
||||||
|
gotcha_gitea = { path = "../gitea" }
|
||||||
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||||
|
tokio.workspace = true
|
||||||
1861
crates/tui/src/app.rs
Normal file
1861
crates/tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
326
crates/tui/src/editor.rs
Normal file
326
crates/tui/src/editor.rs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use gotcha_gitea::{Provider, RepositoryId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EditorAction {
|
||||||
|
Issue {
|
||||||
|
repository: RepositoryId,
|
||||||
|
number: Option<i64>,
|
||||||
|
},
|
||||||
|
Comment {
|
||||||
|
repository: RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
},
|
||||||
|
Milestone {
|
||||||
|
repository: RepositoryId,
|
||||||
|
id: Option<i64>,
|
||||||
|
},
|
||||||
|
Server {
|
||||||
|
original_name: Option<String>,
|
||||||
|
},
|
||||||
|
Settings,
|
||||||
|
IssueFilter,
|
||||||
|
PullFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Field {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub secret: bool,
|
||||||
|
pub multiline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Field {
|
||||||
|
pub fn new(label: &str, value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
value: value.into(),
|
||||||
|
secret: false,
|
||||||
|
multiline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret(mut self) -> Self {
|
||||||
|
self.secret = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn multiline(mut self) -> Self {
|
||||||
|
self.multiline = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Editor {
|
||||||
|
pub title: String,
|
||||||
|
pub fields: Vec<Field>,
|
||||||
|
pub focus: usize,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub action: EditorAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor {
|
||||||
|
pub fn issue(repository: RepositoryId, number: Option<i64>, fields: [String; 6]) -> Self {
|
||||||
|
let [title, body, labels, milestone, due_date, state] = fields;
|
||||||
|
Self::new(
|
||||||
|
if number.is_some() {
|
||||||
|
"Edit issue"
|
||||||
|
} else {
|
||||||
|
"New issue"
|
||||||
|
},
|
||||||
|
EditorAction::Issue { repository, number },
|
||||||
|
vec![
|
||||||
|
Field::new("Title", title),
|
||||||
|
Field::new("Body", body).multiline(),
|
||||||
|
Field::new("Labels (comma separated)", labels),
|
||||||
|
Field::new("Milestone", milestone),
|
||||||
|
Field::new("Due date (YYYY-MM-DD)", due_date),
|
||||||
|
Field::new("State (open/closed)", state),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn comment(
|
||||||
|
repository: RepositoryId,
|
||||||
|
number: i64,
|
||||||
|
comment_id: Option<i64>,
|
||||||
|
body: String,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
if comment_id.is_some() {
|
||||||
|
"Edit comment"
|
||||||
|
} else {
|
||||||
|
"New comment"
|
||||||
|
},
|
||||||
|
EditorAction::Comment {
|
||||||
|
repository,
|
||||||
|
number,
|
||||||
|
comment_id,
|
||||||
|
},
|
||||||
|
vec![Field::new("Comment", body).multiline()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_filter(state: &str, labels: &str, milestone: &str, search: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"Issue filters",
|
||||||
|
EditorAction::IssueFilter,
|
||||||
|
vec![
|
||||||
|
Field::new("State (open/closed/all)", state),
|
||||||
|
Field::new("Labels (comma separated)", labels),
|
||||||
|
Field::new("Milestone", milestone),
|
||||||
|
Field::new("Search", search),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pull_filter(state: &str, milestone: &str, search: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"Pull request filters",
|
||||||
|
EditorAction::PullFilter,
|
||||||
|
vec![
|
||||||
|
Field::new("State (open/closed)", state),
|
||||||
|
Field::new("Milestone", milestone),
|
||||||
|
Field::new("Search", search),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn milestone(
|
||||||
|
repository: RepositoryId,
|
||||||
|
id: Option<i64>,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
due_date: String,
|
||||||
|
state: String,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
if id.is_some() {
|
||||||
|
"Edit milestone"
|
||||||
|
} else {
|
||||||
|
"New milestone"
|
||||||
|
},
|
||||||
|
EditorAction::Milestone { repository, id },
|
||||||
|
vec![
|
||||||
|
Field::new("Title", title),
|
||||||
|
Field::new("Description", description).multiline(),
|
||||||
|
Field::new("Due date (YYYY-MM-DD)", due_date),
|
||||||
|
Field::new("State (open/closed)", state),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server(
|
||||||
|
original_name: Option<String>,
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
provider: Provider,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(
|
||||||
|
if original_name.is_some() {
|
||||||
|
"Edit server"
|
||||||
|
} else {
|
||||||
|
"Add server"
|
||||||
|
},
|
||||||
|
EditorAction::Server { original_name },
|
||||||
|
vec![
|
||||||
|
Field::new("Profile name (host[:port])", name),
|
||||||
|
Field::new("Server URL", url),
|
||||||
|
Field::new("Token (blank keeps existing)", "").secret(),
|
||||||
|
Field::new("Provider (gitea/forgejo)", provider.to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(refresh_seconds: u64) -> Self {
|
||||||
|
Self::new(
|
||||||
|
"TUI preferences",
|
||||||
|
EditorAction::Settings,
|
||||||
|
vec![Field::new(
|
||||||
|
"Auto-reload seconds (0 disables)",
|
||||||
|
refresh_seconds.to_string(),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(title: &str, action: EditorAction, fields: Vec<Field>) -> Self {
|
||||||
|
let cursor = fields
|
||||||
|
.first()
|
||||||
|
.map_or(0, |field| field.value.chars().count());
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
fields,
|
||||||
|
focus: 0,
|
||||||
|
cursor,
|
||||||
|
action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent) -> EditorEvent {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
||||||
|
return EditorEvent::Submit;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => EditorEvent::Cancel,
|
||||||
|
KeyCode::Tab | KeyCode::Down => {
|
||||||
|
self.move_focus(1);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Up => {
|
||||||
|
self.move_focus(self.fields.len().saturating_sub(1));
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.cursor = (self.cursor + 1).min(self.current_len());
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
self.cursor = 0;
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
self.cursor = self.current_len();
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
let end = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||||
|
let start = byte_index(&self.fields[self.focus].value, self.cursor - 1);
|
||||||
|
self.fields[self.focus].value.replace_range(start..end, "");
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
if self.cursor < self.current_len() {
|
||||||
|
let start = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||||
|
let end = byte_index(&self.fields[self.focus].value, self.cursor + 1);
|
||||||
|
self.fields[self.focus].value.replace_range(start..end, "");
|
||||||
|
}
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Enter if self.fields[self.focus].multiline => {
|
||||||
|
self.insert('\n');
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.move_focus(1);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
KeyCode::Char(character)
|
||||||
|
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||||
|
{
|
||||||
|
self.insert(character);
|
||||||
|
EditorEvent::Changed
|
||||||
|
}
|
||||||
|
_ => EditorEvent::Ignored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, character: char) {
|
||||||
|
let index = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||||
|
self.fields[self.focus].value.insert(index, character);
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_focus(&mut self, amount: usize) {
|
||||||
|
self.focus = (self.focus + amount) % self.fields.len();
|
||||||
|
self.cursor = self.current_len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_len(&self) -> usize {
|
||||||
|
self.fields[self.focus].value.chars().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn values(&self) -> Vec<String> {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| field.value.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EditorEvent {
|
||||||
|
Changed,
|
||||||
|
Submit,
|
||||||
|
Cancel,
|
||||||
|
Ignored,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_index(value: &str, character: usize) -> usize {
|
||||||
|
value
|
||||||
|
.char_indices()
|
||||||
|
.nth(character)
|
||||||
|
.map_or(value.len(), |(index, _)| index)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_backspace_edits_instead_of_navigating() {
|
||||||
|
let mut editor = Editor::settings(5);
|
||||||
|
editor.fields[0].value = "é5".into();
|
||||||
|
editor.cursor = 1;
|
||||||
|
assert_eq!(
|
||||||
|
editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
|
||||||
|
EditorEvent::Changed
|
||||||
|
);
|
||||||
|
assert_eq!(editor.fields[0].value, "5");
|
||||||
|
assert_eq!(editor.cursor, 0);
|
||||||
|
assert_eq!(
|
||||||
|
editor.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
|
||||||
|
EditorEvent::Changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
crates/tui/src/main.rs
Normal file
81
crates/tui/src/main.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
mod app;
|
||||||
|
mod editor;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::{env, error::Error, io, time::Duration};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
use gotcha_gitea::Config;
|
||||||
|
|
||||||
|
const HELP: &str = "\
|
||||||
|
Gotcha TUI
|
||||||
|
|
||||||
|
Usage: gotcha-tui [--server SERVER]
|
||||||
|
|
||||||
|
Keyboard:
|
||||||
|
1..5 switch Home, Issues, Repos, PRs, Milestones
|
||||||
|
j/k, arrows move selection
|
||||||
|
n/p or ]/[ next/previous page
|
||||||
|
Enter open selected item
|
||||||
|
Backspace go back (edits text inside an editor)
|
||||||
|
a/e/c/x/d add, edit, comment, toggle state, delete
|
||||||
|
/, v, b, * filters, activity filter, branch, repository favorite
|
||||||
|
f browse files from a repository commit view
|
||||||
|
r reload
|
||||||
|
s choose or manage servers
|
||||||
|
, edit TUI preferences
|
||||||
|
q back, or quit from a root pane
|
||||||
|
|
||||||
|
Editors use Tab/Shift-Tab between fields, normal cursor/editing keys, Ctrl-S to
|
||||||
|
save, and Esc to cancel. Mouse clicks select items, double-click opens them,
|
||||||
|
and the scroll wheel moves through lists.";
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let requested_server = parse_args()?;
|
||||||
|
let config = Config::load()?;
|
||||||
|
let mut app = app::App::new(config, requested_server.as_deref()).await?;
|
||||||
|
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
execute!(io::stdout(), EnableMouseCapture)?;
|
||||||
|
let result = run(&mut terminal, &mut app).await;
|
||||||
|
execute!(io::stdout(), DisableMouseCapture)?;
|
||||||
|
ratatui::restore();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
terminal: &mut ratatui::DefaultTerminal,
|
||||||
|
app: &mut app::App,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
while !app.should_quit {
|
||||||
|
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key).await,
|
||||||
|
Event::Mouse(mouse) => app.handle_mouse(mouse).await,
|
||||||
|
Event::Resize(_, _) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if app.should_auto_reload() {
|
||||||
|
app.reload().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Option<String>, String> {
|
||||||
|
let args: Vec<_> = env::args().skip(1).collect();
|
||||||
|
match args.as_slice() {
|
||||||
|
[] => Ok(None),
|
||||||
|
[help] if matches!(help.as_str(), "-h" | "--help" | "help") => {
|
||||||
|
println!("{HELP}");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
[option, server] if option == "--server" => Ok(Some(server.clone())),
|
||||||
|
_ => Err(format!("invalid arguments\n\n{HELP}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
271
crates/tui/src/ui.rs
Normal file
271
crates/tui/src/ui.rs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::{App, Tab},
|
||||||
|
editor::Editor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCENT: Color = Color::Cyan;
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
draw_tabs(frame, app, rows[0]);
|
||||||
|
draw_content(frame, app, rows[1]);
|
||||||
|
draw_footer(frame, app, rows[2]);
|
||||||
|
|
||||||
|
if let Some(editor) = &app.editor {
|
||||||
|
draw_editor(frame, editor);
|
||||||
|
} else if let Some((message, _)) = &app.confirm {
|
||||||
|
draw_confirm(frame, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let titles = Tab::ALL.map(|tab| Line::from(tab.title()));
|
||||||
|
let selected = Tab::ALL.iter().position(|tab| *tab == app.tab).unwrap_or(0);
|
||||||
|
let tabs = Tabs::new(titles)
|
||||||
|
.select(selected)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" Gotcha · {} ", app.server_name)),
|
||||||
|
)
|
||||||
|
.highlight_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
|
||||||
|
.divider("│");
|
||||||
|
frame.render_widget(tabs, area);
|
||||||
|
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 1,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
app.tab_areas = Layout::horizontal([Constraint::Ratio(1, 5); 5])
|
||||||
|
.split(inner)
|
||||||
|
.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let direction = content_direction(area.width);
|
||||||
|
let panes = Layout::default()
|
||||||
|
.direction(direction)
|
||||||
|
.constraints(if direction == Direction::Horizontal {
|
||||||
|
[Constraint::Percentage(42), Constraint::Percentage(58)]
|
||||||
|
} else {
|
||||||
|
[Constraint::Percentage(55), Constraint::Percentage(45)]
|
||||||
|
})
|
||||||
|
.split(area);
|
||||||
|
app.list_area = panes[0];
|
||||||
|
draw_list(frame, app, panes[0]);
|
||||||
|
draw_detail(frame, app, panes[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_direction(width: u16) -> Direction {
|
||||||
|
if width >= 90 {
|
||||||
|
Direction::Horizontal
|
||||||
|
} else {
|
||||||
|
Direction::Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let items = if app.screen.items.is_empty() {
|
||||||
|
vec![ListItem::new(Line::styled(
|
||||||
|
"No items",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
))]
|
||||||
|
} else {
|
||||||
|
app.screen
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
ListItem::new(vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
item.title.clone(),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
item.meta.clone(),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" {} ", app.screen.title)),
|
||||||
|
)
|
||||||
|
.highlight_symbol("▸ ")
|
||||||
|
.highlight_style(Style::default().fg(ACCENT));
|
||||||
|
let mut state = ListState::default()
|
||||||
|
.with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected));
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let mut detail = app.screen.detail.clone();
|
||||||
|
if let Some(item) = app.screen.selected_item()
|
||||||
|
&& !item.detail.is_empty()
|
||||||
|
{
|
||||||
|
if !detail.is_empty() {
|
||||||
|
detail.push_str("\n\n────\n\n");
|
||||||
|
}
|
||||||
|
detail.push_str(&item.detail);
|
||||||
|
}
|
||||||
|
if detail.is_empty() {
|
||||||
|
detail = "Select an item to see details.".into();
|
||||||
|
}
|
||||||
|
let paragraph = Paragraph::new(Text::from(detail))
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Preview "))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((app.screen.detail_scroll, 0));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let status = if app.status.is_empty() {
|
||||||
|
format!(
|
||||||
|
"Page {} · j/k move · Enter open · Backspace back · n/p pages · a/e/c/x/d actions · / filters · * favorite · r reload · q quit",
|
||||||
|
app.screen.page
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} · Page {} · j/k move · Enter open · Backspace back · n/p pages · q quit",
|
||||||
|
app.status, app.screen.page
|
||||||
|
)
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(status)
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_editor(frame: &mut Frame<'_>, editor: &Editor) {
|
||||||
|
let area = centered(frame.area(), 86, 86);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(
|
||||||
|
" {} · Tab fields · Ctrl-S save · Esc cancel ",
|
||||||
|
editor.title
|
||||||
|
))
|
||||||
|
.border_style(Style::default().fg(ACCENT)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
let inner = area.inner(ratatui::layout::Margin {
|
||||||
|
horizontal: 2,
|
||||||
|
vertical: 1,
|
||||||
|
});
|
||||||
|
let heights = editor
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| Constraint::Length(if field.multiline { 5 } else { 3 }));
|
||||||
|
let fields = Layout::vertical(heights).split(inner);
|
||||||
|
for (index, (field, field_area)) in editor.fields.iter().zip(fields.iter()).enumerate() {
|
||||||
|
let value = if field.secret {
|
||||||
|
"•".repeat(field.value.chars().count())
|
||||||
|
} else {
|
||||||
|
field.value.clone()
|
||||||
|
};
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!(" {} ", field.label))
|
||||||
|
.border_style(if index == editor.focus {
|
||||||
|
Style::default().fg(ACCENT)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
});
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(value)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
*field_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(field_area) = fields.get(editor.focus) {
|
||||||
|
let prefix: String = editor.fields[editor.focus]
|
||||||
|
.value
|
||||||
|
.chars()
|
||||||
|
.take(editor.cursor)
|
||||||
|
.collect();
|
||||||
|
let lines: Vec<_> = prefix.split('\n').collect();
|
||||||
|
let x = field_area.x
|
||||||
|
+ 1
|
||||||
|
+ lines
|
||||||
|
.last()
|
||||||
|
.map_or(0, |line| line.chars().count())
|
||||||
|
.min(field_area.width.saturating_sub(3) as usize) as u16;
|
||||||
|
let y = field_area.y
|
||||||
|
+ 1
|
||||||
|
+ (lines.len().saturating_sub(1)).min(field_area.height.saturating_sub(3) as usize)
|
||||||
|
as u16;
|
||||||
|
frame.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm(frame: &mut Frame<'_>, message: &str) {
|
||||||
|
let area = centered(frame.area(), 64, 24);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"{message}\n\nPress y or Enter to confirm; n or Esc to cancel."
|
||||||
|
))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Confirm destructive action ")
|
||||||
|
.border_style(Style::default().fg(Color::Red)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
|
||||||
|
let vertical = Layout::vertical([
|
||||||
|
Constraint::Percentage((100 - height_percent) / 2),
|
||||||
|
Constraint::Percentage(height_percent),
|
||||||
|
Constraint::Percentage((100 - height_percent) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
Layout::horizontal([
|
||||||
|
Constraint::Percentage((100 - width_percent) / 2),
|
||||||
|
Constraint::Percentage(width_percent),
|
||||||
|
Constraint::Percentage((100 - width_percent) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responsive_popup_stays_inside_small_terminals() {
|
||||||
|
let area = centered(Rect::new(0, 0, 40, 20), 86, 86);
|
||||||
|
assert!(area.width <= 40);
|
||||||
|
assert!(area.height <= 20);
|
||||||
|
assert!(area.width > 0);
|
||||||
|
assert!(area.height > 0);
|
||||||
|
assert_eq!(content_direction(80), Direction::Vertical);
|
||||||
|
assert_eq!(content_direction(120), Direction::Horizontal);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user