mod app;
mod config;
mod curl;
mod highlight;
mod http;
mod keymap;
mod model;
mod oauth;
mod openapi;
mod theme;
mod ui;
mod vars;
mod vimext;
mod workspace;
use anyhow::{Result, bail};
use app::{App, Msg};
use clap::{Parser, Subcommand};
use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyEventKind,
};
use std::path::PathBuf;
use std::time::Duration;
use workspace::{Workspace, registry, slug};
#[derive(Parser)]
#[command(name = "turly", version, about = "A terminal API client with Vim keys")]
struct Cli {
path: Option<PathBuf>,
#[arg(short, long, value_name = "SPEC", conflicts_with = "path")]
import: Option<String>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
Init {
path: PathBuf,
#[arg(long)]
name: Option<String>,
},
Import {
spec: String,
#[arg(long)]
dir: Option<PathBuf>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let rt = tokio::runtime::Runtime::new()?;
match cli.cmd {
Some(Cmd::Init { path, name }) => {
let name = name
.or_else(|| path.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "workspace".into());
let ws = Workspace::init(&path, &name)?;
registry::add(&ws.root)?;
println!("Created workspace '{name}' at {}", path.display());
Ok(())
}
Some(Cmd::Import { spec, dir }) => rt.block_on(import_spec(&spec, dir, true)).map(|_| ()),
None => {
let Some(spec) = cli.import else {
return run_tui(rt, cli.path);
};
let tmp = std::env::temp_dir().join(format!("turly-{}", std::process::id()));
let res = rt
.block_on(import_spec(&spec, Some(tmp.clone()), false))
.and_then(|_| run_tui(rt, Some(tmp.clone())));
let _ = std::fs::remove_dir_all(&tmp);
res
}
}
}
async fn import_spec(spec: &str, dir: Option<PathBuf>, persist: bool) -> Result<PathBuf> {
let imp = openapi::import(&openapi::fetch(spec).await?)?;
let dir = dir.unwrap_or_else(|| PathBuf::from(slug(&imp.title)));
let ws = openapi::build_workspace(&dir, &imp)?;
if persist {
registry::add(&ws.root)?;
println!(
"Imported '{}': {} requests, {} environments -> {}",
imp.title,
imp.requests.len(),
imp.envs.len(),
dir.display()
);
}
Ok(dir)
}
fn pick_workspace(path: Option<PathBuf>) -> Result<(Option<Workspace>, Option<PathBuf>)> {
let explicit = path.is_some();
let Some(root) = path.or_else(|| std::env::current_dir().ok()) else {
return Ok((
registry::load()
.last()
.and_then(|p| Workspace::open(p).ok()),
None,
));
};
let found = workspace::discover(&root);
let pick = registry::load()
.iter()
.rev()
.find_map(|r| found.iter().find(|f| app::same_dir(f, r)))
.or(found.first());
if let Some(p) = pick {
return Ok((Some(Workspace::open(p)?), Some(root)));
}
if explicit {
bail!(
"no Turly workspace in {} (try `turly init {0}`)",
root.display()
);
}
Ok((
registry::load()
.last()
.and_then(|p| Workspace::open(p).ok()),
None,
))
}
fn run_tui(rt: tokio::runtime::Runtime, path: Option<PathBuf>) -> Result<()> {
let (ws, scope) = pick_workspace(path)?;
let (cfg, cfg_err) = match config::Config::load() {
Ok(c) => (c, None),
Err(e) => (Default::default(), Some(format!("{e:#}"))),
};
let mouse = cfg.mouse.unwrap_or(true);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Msg>();
let mut app = App::new(ws, cfg, rt.handle().clone(), tx);
if let Some(root) = scope {
app.set_scope(&root);
}
if let Some(e) = cfg_err {
app.err(e);
}
let mut terminal = ratatui::init();
crossterm::execute!(std::io::stdout(), EnableBracketedPaste)?;
if mouse {
crossterm::execute!(std::io::stdout(), EnableMouseCapture)?;
}
let result = run_loop(&mut terminal, &mut app, &mut rx);
if mouse {
let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture);
}
let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste);
ratatui::restore();
result
}
fn run_loop(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<Msg>,
) -> Result<()> {
while !app.quit {
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Key(k) if k.kind != KeyEventKind::Release => app.on_key(k),
Event::Paste(s) => app.on_paste(&s),
Event::Mouse(m) => app.on_mouse(m),
_ => {}
}
}
while let Ok(m) = rx.try_recv() {
app.on_msg(m);
}
app.run_system_editor(terminal)?;
}
Ok(())
}