first commit

This commit is contained in:
2026-04-11 14:11:11 -06:00
commit ed34c56e31
6 changed files with 2819 additions and 0 deletions

213
src/main.rs Normal file
View File

@@ -0,0 +1,213 @@
mod api;
mod app;
mod ui;
use std::{io, sync::mpsc, time::Duration};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use api::{do_login, fetch_pages, fetch_spaces};
use app::{App, AppMsg, AppState, LoginField, Panel};
use ui::{draw_login, draw_main};
// ─── Entry point ──────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
// ─── App loop ─────────────────────────────────────────────────────────────────
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let mut app = App::new();
let (tx, rx) = mpsc::channel::<AppMsg>();
loop {
// Drain async messages
while let Ok(msg) = rx.try_recv() {
match msg {
AppMsg::LoginSuccess { token, base_url } => {
app.token = token.clone();
app.base_url = base_url.clone();
app.state = AppState::Main;
app.login.submitting = false;
let tx2 = tx.clone();
tokio::spawn(async move {
let msg = match fetch_spaces(&base_url, &token).await {
Ok(spaces) => AppMsg::SpacesLoaded(spaces),
Err(e) => AppMsg::ApiError(e),
};
let _ = tx2.send(msg);
});
}
AppMsg::LoginError(e) => {
app.login.error = Some(e);
app.login.submitting = false;
}
AppMsg::SpacesLoaded(spaces) => {
app.main.loading_spaces = false;
app.main.spaces = spaces;
app.main.selected_space = 0;
app.main.pages = vec![];
if let Some(space) = app.main.spaces.first() {
app.main.loading_pages = true;
let tx2 = tx.clone();
let base_url = app.base_url.clone();
let token = app.token.clone();
let space_id = space.id.clone();
tokio::spawn(async move {
let msg = match fetch_pages(&base_url, &token, &space_id).await {
Ok(pages) => AppMsg::PagesLoaded(pages),
Err(e) => AppMsg::ApiError(e),
};
let _ = tx2.send(msg);
});
}
}
AppMsg::PagesLoaded(pages) => {
app.main.loading_pages = false;
app.main.pages = pages;
app.main.selected_page = 0;
}
AppMsg::ApiError(e) => {
app.main.loading_spaces = false;
app.main.loading_pages = false;
app.main.error = Some(e);
}
}
}
terminal.draw(|f| match app.state {
AppState::Login => draw_login(f, &app.login),
AppState::Main => draw_main(f, &app.main),
})?;
if !event::poll(Duration::from_millis(50))? {
continue;
}
if let Event::Key(key) = event::read()? {
match app.state {
AppState::Login => {
if app.login.submitting {
continue;
}
match key.code {
KeyCode::Esc => return Ok(()),
KeyCode::Tab => app.login.active_field = app.login.active_field.next(),
KeyCode::BackTab => app.login.active_field = app.login.active_field.prev(),
KeyCode::Up => app.login.active_field = app.login.active_field.prev(),
KeyCode::Down => app.login.active_field = app.login.active_field.next(),
KeyCode::Enter => {
if app.login.active_field != LoginField::Password {
app.login.active_field = app.login.active_field.next();
} else {
app.login.error = None;
app.login.submitting = true;
let url = app.login.url.trim_end_matches('/').to_string();
let email = app.login.email.clone();
let password = app.login.password.clone();
let tx2 = tx.clone();
tokio::spawn(async move {
let msg = match do_login(&url, &email, &password).await {
Ok(token) => AppMsg::LoginSuccess { token, base_url: url },
Err(e) => AppMsg::LoginError(e),
};
let _ = tx2.send(msg);
});
}
}
KeyCode::Backspace => {
app.login.active_field_value_mut().pop();
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' {
return Ok(());
}
app.login.active_field_value_mut().push(c);
}
_ => {}
}
}
AppState::Main => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(())
}
KeyCode::Tab => {
app.main.focus = match app.main.focus {
Panel::Spaces => Panel::Pages,
Panel::Pages => Panel::Spaces,
};
}
KeyCode::Down | KeyCode::Char('j') => match app.main.focus {
Panel::Spaces => {
if app.main.selected_space + 1 < app.main.spaces.len() {
app.main.selected_space += 1;
spawn_fetch_pages(&app, &tx);
}
}
Panel::Pages => {
if app.main.selected_page + 1 < app.main.pages.len() {
app.main.selected_page += 1;
}
}
},
KeyCode::Up | KeyCode::Char('k') => match app.main.focus {
Panel::Spaces => {
if app.main.selected_space > 0 {
app.main.selected_space -= 1;
spawn_fetch_pages(&app, &tx);
}
}
Panel::Pages => {
if app.main.selected_page > 0 {
app.main.selected_page -= 1;
}
}
},
_ => {}
},
}
}
}
}
fn spawn_fetch_pages(app: &App, tx: &mpsc::Sender<AppMsg>) {
if let Some(space) = app.main.spaces.get(app.main.selected_space) {
let base_url = app.base_url.clone();
let token = app.token.clone();
let space_id = space.id.clone();
let tx2 = tx.clone();
tokio::spawn(async move {
let msg = match fetch_pages(&base_url, &token, &space_id).await {
Ok(pages) => AppMsg::PagesLoaded(pages),
Err(e) => AppMsg::ApiError(e),
};
let _ = tx2.send(msg);
});
}
}