mod api;
mod app;
mod auth;
mod cli;
mod config;
mod dispatch;
mod event;
mod fuzzy;
mod grouping;
mod herdr;
mod keys;
mod logging;
mod message;
mod palette;
mod private_file;
mod snapshot;
mod store;
mod ui;
mod usecase;
use std::io::{self, Write};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
event::{
DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
supports_keyboard_enhancement,
},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::sync::mpsc;
use base64::Engine;
use api::client::LinearClient;
use app::App;
use auth::token::TokenStore;
use config::Config;
use message::Message;
const TICK: Duration = Duration::from_millis(80);
#[tokio::main]
async fn main() -> Result<()> {
let _log_guard = logging::init();
let args: Vec<String> = std::env::args().collect();
let open = match args.get(1).map(String::as_str) {
Some("open") => {
let [_, _, key] = args.as_slice() else {
anyhow::bail!("Usage: linear-tui open <ID>");
};
Some(cli::issue_key(key)?)
}
Some(_) => return cli::handle_subcommand(&args[1..]).await,
None => None,
};
let mut config = Config::load()?;
let token_store = TokenStore::new()?;
let Some(auth) = authenticate(&token_store, &config).await? else {
return Ok(());
};
tracing::info!(method = auth.label(), "authenticated successfully");
let client = LinearClient::new(auth.into_credentials(token_store));
let origin = snapshot::Origin::current(std::env::current_dir()?);
let pinned = config.workspace(&origin.workspace, &origin.cwd).cloned();
let shelf = snapshot::state_dir()
.map(|dir| snapshot::Shelf::new(&dir, &origin.workspace))
.inspect_err(|e| tracing::warn!("snapshots disabled: {e:#}"))
.ok();
let restored = match &pinned {
Some(entry) => {
if entry.team.is_some() {
config.ui.default_team.clone_from(&entry.team);
}
None
}
None if open.is_some() => None,
None => shelf.as_ref().and_then(|s| s.for_restore(origin.pid)),
};
let recorder = shelf.map(|shelf| {
shelf.prune();
snapshot::Recorder::new(&shelf, origin.pid)
});
run_tui(
client,
config,
Session {
origin,
restored,
open,
recorder,
},
)
.await
}
struct Session {
origin: snapshot::Origin,
restored: Option<snapshot::ViewSnapshot>,
open: Option<String>,
recorder: Option<snapshot::Recorder>,
}
async fn authenticate(
token_store: &TokenStore,
config: &Config,
) -> Result<Option<auth::AuthMethod>> {
if auth::has_credentials(token_store, config)? {
return auth::resolve_auth(token_store, config.auth.api_key.as_deref())
.await
.map(Some);
}
if !auth::setup::run(token_store).await? {
return Ok(None);
}
let config = Config::load()?;
auth::resolve_auth(token_store, config.auth.api_key.as_deref())
.await
.map(Some)
}
struct TerminalGuard {
enhanced_keys: bool,
}
impl TerminalGuard {
fn enter() -> Result<Self> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
let enhanced_keys = supports_keyboard_enhancement().unwrap_or(false);
if enhanced_keys {
execute!(
io::stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
}
Ok(Self { enhanced_keys })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
if self.enhanced_keys {
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
}
let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
async fn run_tui(client: LinearClient, config: Config, session: Session) -> Result<()> {
let Session {
origin,
restored,
open,
mut recorder,
} = session;
let _guard = TerminalGuard::enter()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
let client = Arc::new(client);
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
let mut app = App::new(&config);
let mut agents = app.herdr.then(herdr::AgentWatch::new).flatten();
if let Some(snapshot) = restored {
tracing::info!(updated_at = %snapshot.updated_at, "restoring the last view");
app.restore(snapshot);
}
if let Some(identifier) = &open {
app.open_on_launch(identifier);
}
let mut cache = ui::Cache::default();
let mut last_tick = Instant::now();
let mut dirty = true;
loop {
if app.flush_palette_search(Instant::now()) {
dirty = true;
}
while let Some(req) = app.outbox.requests.pop_front() {
app.outbox.inflight += 1;
let client = Arc::clone(&client);
let tx = tx.clone();
let per_page = app.items_per_page;
tokio::spawn(async move {
let msg = dispatch::execute_request(&client, req, per_page).await;
let _ = tx.send(msg);
});
dirty = true;
}
if dirty {
terminal.draw(|f| ui::draw(f, &mut app, &mut cache))?;
dirty = false;
}
let mut moved = false;
while let Ok(msg) = rx.try_recv() {
app.outbox.inflight = app.outbox.inflight.saturating_sub(1);
app.handle_message(msg);
moved = true;
}
if let Some(text) = app.outbox.clipboard.take() {
copy_to_clipboard(&text)?;
}
if event::poll_and_handle(&mut app)? {
moved = true;
}
let now = Instant::now();
if let Some(recorder) = &mut recorder {
if moved {
recorder.touch(now);
}
if recorder.is_due(now)
&& let Some(snapshot) = app.snapshot(&origin)
{
recorder.record(snapshot);
}
}
dirty |= moved;
if let Some(watch) = &mut agents
&& let Some(list) = watch.poll(now)
{
app.set_agents(list);
dirty = true;
}
if app.loading() && last_tick.elapsed() >= TICK {
app.tick_spinner();
last_tick = Instant::now();
dirty = true;
}
if app.should_quit {
break;
}
}
if let Some(recorder) = &mut recorder
&& let Some(snapshot) = app.snapshot(&origin)
{
recorder.close(snapshot);
}
Ok(())
}
fn copy_to_clipboard(text: &str) -> Result<()> {
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
let mut stdout = io::stdout();
write!(stdout, "\x1b]52;c;{encoded}\x07")?;
stdout.flush()?;
Ok(())
}