| 34 |
34 |
|
use input::*;
|
| 35 |
35 |
|
use loading::*;
|
| 36 |
36 |
|
|
|
37 |
+ |
mod run;
|
|
38 |
+ |
pub use run::launch;
|
|
39 |
+ |
|
| 37 |
40 |
|
/// Events sent to the TUI event loop.
|
| 38 |
41 |
|
pub enum AppEvent {
|
| 39 |
42 |
|
/// Raw input bytes from the SSH channel.
|
| 382 |
385 |
|
}
|
| 383 |
386 |
|
}
|
| 384 |
387 |
|
|
| 385 |
|
- |
/// Launch the TUI event loop in a background task.
|
| 386 |
|
- |
#[allow(clippy::too_many_arguments)]
|
| 387 |
|
- |
pub fn launch(
|
| 388 |
|
- |
writer: TerminalHandle,
|
| 389 |
|
- |
user: UserInfo,
|
| 390 |
|
- |
cols: u16,
|
| 391 |
|
- |
rows: u16,
|
| 392 |
|
- |
session_handle: russh::server::Handle,
|
| 393 |
|
- |
channel_id: russh::ChannelId,
|
| 394 |
|
- |
api: MnwApiClient,
|
| 395 |
|
- |
staging_dir: PathBuf,
|
| 396 |
|
- |
) -> anyhow::Result<AppHandle> {
|
| 397 |
|
- |
let mut writer = writer;
|
| 398 |
|
- |
// Enter alternate screen, hide cursor, enable raw-like mode
|
| 399 |
|
- |
use std::io::Write;
|
| 400 |
|
- |
let _ = writer.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
|
| 401 |
|
- |
let _ = writer.flush();
|
| 402 |
|
- |
let backend = CrosstermBackend::new(writer);
|
| 403 |
|
- |
let options = ratatui::TerminalOptions {
|
| 404 |
|
- |
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)),
|
| 405 |
|
- |
};
|
| 406 |
|
- |
let mut terminal = Terminal::with_options(backend, options)?;
|
| 407 |
|
- |
|
| 408 |
|
- |
let (tx, mut rx) = mpsc::channel::<AppEvent>(64);
|
| 409 |
|
- |
let handle = AppHandle { tx: tx.clone() };
|
| 410 |
|
- |
|
| 411 |
|
- |
// Kick off initial data load
|
| 412 |
|
- |
let user_id = user.user_id.clone();
|
| 413 |
|
- |
let api_clone = api.clone();
|
| 414 |
|
- |
let tx_clone = tx.clone();
|
| 415 |
|
- |
tokio::spawn(async move {
|
| 416 |
|
- |
load_home_data(&api_clone, &user_id, &tx_clone).await;
|
| 417 |
|
- |
});
|
| 418 |
|
- |
|
| 419 |
|
- |
tokio::spawn(async move {
|
| 420 |
|
- |
let mut app = App::new(user);
|
| 421 |
|
- |
let mut screen = Screen::Home;
|
| 422 |
|
- |
let staging_dir = staging_dir;
|
| 423 |
|
- |
|
| 424 |
|
- |
/// Write escape codes to leave alternate screen and restore cursor.
|
| 425 |
|
- |
fn cleanup(terminal: &mut Terminal<CrosstermBackend<TerminalHandle>>) {
|
| 426 |
|
- |
use std::io::Write;
|
| 427 |
|
- |
let be = terminal.backend_mut();
|
| 428 |
|
- |
let _ = be.write_all(b"\x1b[?25h\x1b[?1049l");
|
| 429 |
|
- |
let _ = be.flush();
|
| 430 |
|
- |
}
|
| 431 |
|
- |
|
| 432 |
|
- |
// Initial render (loading state)
|
| 433 |
|
- |
if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) {
|
| 434 |
|
- |
tracing::error!(error = ?e, "TUI: initial render failed");
|
| 435 |
|
- |
cleanup(&mut terminal);
|
| 436 |
|
- |
return;
|
| 437 |
|
- |
}
|
| 438 |
|
- |
|
| 439 |
|
- |
while let Some(event) = rx.recv().await {
|
| 440 |
|
- |
match event {
|
| 441 |
|
- |
AppEvent::Input(data) => {
|
| 442 |
|
- |
if let Some(key) = parse_key(&data) {
|
| 443 |
|
- |
// Global quit
|
| 444 |
|
- |
if (key.modifiers.contains(KeyModifiers::CONTROL)
|
| 445 |
|
- |
&& key.code == KeyCode::Char('c'))
|
| 446 |
|
- |
|| matches!(
|
| 447 |
|
- |
(&screen, key.code),
|
| 448 |
|
- |
(Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q'))
|
| 449 |
|
- |
)
|
| 450 |
|
- |
{
|
| 451 |
|
- |
tracing::info!(user = %app.user.username, "user quit");
|
| 452 |
|
- |
cleanup(&mut terminal);
|
| 453 |
|
- |
let _ = session_handle.close(channel_id).await;
|
| 454 |
|
- |
return;
|
| 455 |
|
- |
}
|
| 456 |
|
- |
|
| 457 |
|
- |
match screen {
|
| 458 |
|
- |
Screen::Home => {
|
| 459 |
|
- |
handle_home_input(
|
| 460 |
|
- |
key, &mut app, &mut screen, &api, &tx, &staging_dir,
|
| 461 |
|
- |
)
|
| 462 |
|
- |
.await;
|
| 463 |
|
- |
}
|
| 464 |
|
- |
Screen::Project(_) => {
|
| 465 |
|
- |
handle_project_input(
|
| 466 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 467 |
|
- |
)
|
| 468 |
|
- |
.await;
|
| 469 |
|
- |
}
|
| 470 |
|
- |
Screen::Upload => {
|
| 471 |
|
- |
handle_upload_input(
|
| 472 |
|
- |
key, &mut app, &mut screen, &api, &tx, &staging_dir,
|
| 473 |
|
- |
)
|
| 474 |
|
- |
.await;
|
| 475 |
|
- |
}
|
| 476 |
|
- |
Screen::Item(..) => {
|
| 477 |
|
- |
handle_item_input(
|
| 478 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 479 |
|
- |
)
|
| 480 |
|
- |
.await;
|
| 481 |
|
- |
}
|
| 482 |
|
- |
Screen::Blog(..) => {
|
| 483 |
|
- |
handle_blog_input(
|
| 484 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 485 |
|
- |
)
|
| 486 |
|
- |
.await;
|
| 487 |
|
- |
}
|
| 488 |
|
- |
Screen::Promo => {
|
| 489 |
|
- |
handle_promo_input(
|
| 490 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 491 |
|
- |
)
|
| 492 |
|
- |
.await;
|
| 493 |
|
- |
}
|
| 494 |
|
- |
Screen::Keys(..) => {
|
| 495 |
|
- |
handle_keys_input(
|
| 496 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 497 |
|
- |
)
|
| 498 |
|
- |
.await;
|
| 499 |
|
- |
}
|
| 500 |
|
- |
Screen::Analytics => {
|
| 501 |
|
- |
handle_analytics_input(
|
| 502 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 503 |
|
- |
)
|
| 504 |
|
- |
.await;
|
| 505 |
|
- |
}
|
| 506 |
|
- |
Screen::Settings => {
|
| 507 |
|
- |
handle_settings_input(
|
| 508 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 509 |
|
- |
)
|
| 510 |
|
- |
.await;
|
| 511 |
|
- |
}
|
| 512 |
|
- |
Screen::Collections => {
|
| 513 |
|
- |
handle_collections_input(
|
| 514 |
|
- |
key, &mut app, &mut screen, &api, &tx,
|
| 515 |
|
- |
)
|
| 516 |
|
- |
.await;
|
| 517 |
|
- |
}
|
| 518 |
|
- |
Screen::Tiers(..) => {
|
| 519 |
|
- |
handle_tiers_input(
|
| 520 |
|
- |
key, &mut app, &mut screen,
|
| 521 |
|
- |
)
|
| 522 |
|
- |
.await;
|
| 523 |
|
- |
}
|
| 524 |
|
- |
}
|
| 525 |
|
- |
}
|
| 526 |
|
- |
}
|
| 527 |
|
- |
AppEvent::Resize(cols, rows) => {
|
| 528 |
|
- |
let rect = ratatui::layout::Rect::new(0, 0, cols, rows);
|
| 529 |
|
- |
let _ = terminal.resize(rect);
|
| 530 |
|
- |
}
|
| 531 |
|
- |
AppEvent::DataLoaded(payload) => match payload {
|
| 532 |
|
- |
DataPayload::Home { projects, stats } => {
|
| 533 |
|
- |
app.projects = projects;
|
| 534 |
|
- |
app.stats = Some(stats);
|
| 535 |
|
- |
app.loading = false;
|
| 536 |
|
- |
app.selected_index = 0;
|
| 537 |
|
- |
}
|
| 538 |
|
- |
DataPayload::ProjectItems { items } => {
|
| 539 |
|
- |
app.items = items;
|
| 540 |
|
- |
app.loading = false;
|
| 541 |
|
- |
app.selected_index = 0;
|
| 542 |
|
- |
app.selected_items.clear();
|
| 543 |
|
- |
}
|
| 544 |
|
- |
DataPayload::StagedFiles { files, storage } => {
|
| 545 |
|
- |
app.staged_files = files;
|
| 546 |
|
- |
if let Some(s) = storage {
|
| 547 |
|
- |
app.storage_info = Some(s);
|
| 548 |
|
- |
}
|
| 549 |
|
- |
app.sync_metadata();
|
| 550 |
|
- |
app.loading = false;
|
| 551 |
|
- |
if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() {
|
| 552 |
|
- |
app.selected_index = app.staged_files.len() - 1;
|
| 553 |
|
- |
}
|
| 554 |
|
- |
}
|
| 555 |
|
- |
DataPayload::ItemDetail { detail, versions } => {
|
| 556 |
|
- |
app.item_detail = Some(detail);
|
| 557 |
|
- |
app.item_versions = versions;
|
| 558 |
|
- |
app.loading = false;
|
| 559 |
|
- |
app.selected_index = 0;
|
| 560 |
|
- |
}
|
| 561 |
|
- |
DataPayload::ItemUpdated { detail } => {
|
| 562 |
|
- |
app.item_detail = Some(detail);
|
| 563 |
|
- |
app.item_status = Some("Updated".to_string());
|
| 564 |
|
- |
app.item_editing = None;
|
| 565 |
|
- |
app.edit_buffer.clear();
|
| 566 |
|
- |
}
|
| 567 |
|
- |
DataPayload::ItemDeleted => {
|
| 568 |
|
- |
app.item_status = Some("Deleted".to_string());
|
| 569 |
|
- |
// Navigate back to project view
|
| 570 |
|
- |
if let Screen::Item(project_idx, _) = &screen {
|
| 571 |
|
- |
let pidx = *project_idx;
|
| 572 |
|
- |
screen = Screen::Project(pidx);
|
| 573 |
|
- |
app.item_detail = None;
|
| 574 |
|
- |
app.item_versions.clear();
|
| 575 |
|
- |
app.item_status = None;
|
| 576 |
|
- |
app.selected_index = 0;
|
| 577 |
|
- |
app.loading = true;
|
| 578 |
|
- |
|
| 579 |
|
- |
if let Some(p) = app.projects.get(pidx) {
|
| 580 |
|
- |
let api = api.clone();
|
| 581 |
|
- |
let project_id = p.id.clone();
|
| 582 |
|
- |
let user_id = app.user.user_id.clone();
|
| 583 |
|
- |
let tx = tx.clone();
|
| 584 |
|
- |
tokio::spawn(async move {
|
| 585 |
|
- |
load_project_items(&api, &project_id, &user_id, &tx).await;
|
| 586 |
|
- |
});
|
| 587 |
|
- |
}
|
| 588 |
|
- |
}
|
| 589 |
|
- |
}
|
| 590 |
|
- |
DataPayload::ItemActionError { error } => {
|
| 591 |
|
- |
app.item_status = Some(format!("Error: {}", error));
|
| 592 |
|
- |
}
|
| 593 |
|
- |
DataPayload::BlogPosts { posts } => {
|
| 594 |
|
- |
app.blog_posts = posts;
|
| 595 |
|
- |
app.loading = false;
|
| 596 |
|
- |
app.selected_index = 0;
|
| 597 |
|
- |
}
|
| 598 |
|
- |
DataPayload::BlogCreated => {
|
| 599 |
|
- |
app.blog_creating = false;
|
| 600 |
|
- |
app.blog_create_step = None;
|
| 601 |
|
- |
app.blog_create_title.clear();
|
| 602 |
|
- |
app.blog_create_body.clear();
|
| 603 |
|
- |
app.edit_buffer.clear();
|
| 604 |
|
- |
app.blog_status = Some("Post created".to_string());
|
| 605 |
|
- |
// Reload blog posts
|
| 606 |
|
- |
if let Screen::Blog(_, ref project_id) = screen {
|
| 607 |
|
- |
let api = api.clone();
|
| 608 |
|
- |
let user_id = app.user.user_id.clone();
|
| 609 |
|
- |
let project_id = project_id.clone();
|
| 610 |
|
- |
let tx = tx.clone();
|
| 611 |
|
- |
tokio::spawn(async move {
|
| 612 |
|
- |
load_blog_posts(&api, &user_id, &project_id, &tx).await;
|
| 613 |
|
- |
});
|
| 614 |
|
- |
}
|
| 615 |
|
- |
}
|
| 616 |
|
- |
DataPayload::PromoCodes { codes } => {
|
| 617 |
|
- |
app.promo_codes = codes;
|
| 618 |
|
- |
app.loading = false;
|
| 619 |
|
- |
app.selected_index = 0;
|
| 620 |
|
- |
}
|
| 621 |
|
- |
DataPayload::LicenseKeys { keys: k } => {
|
| 622 |
|
- |
app.license_keys = k;
|
| 623 |
|
- |
app.loading = false;
|
| 624 |
|
- |
app.selected_index = 0;
|
| 625 |
|
- |
}
|
| 626 |
|
- |
DataPayload::GenericSuccess { message } => {
|
| 627 |
|
- |
// Use as status on whatever screen is active
|
| 628 |
|
- |
match screen {
|
| 629 |
|
- |
Screen::Blog(..) => app.blog_status = Some(message),
|
| 630 |
|
- |
Screen::Promo => app.promo_status = Some(message),
|
| 631 |
|
- |
Screen::Keys(..) => app.keys_status = Some(message),
|
| 632 |
|
- |
_ => {}
|
| 633 |
|
- |
}
|
| 634 |
|
- |
}
|
| 635 |
|
- |
DataPayload::GenericError { error } => {
|
| 636 |
|
- |
let msg = format!("Error: {}", error);
|
| 637 |
|
- |
match screen {
|
| 638 |
|
- |
Screen::Blog(..) => app.blog_status = Some(msg),
|
| 639 |
|
- |
Screen::Promo => {
|
| 640 |
|
- |
app.promo_status = Some(msg);
|
| 641 |
|
- |
app.promo_editing_step = None;
|
| 642 |
|
- |
}
|
| 643 |
|
- |
Screen::Keys(..) => app.keys_status = Some(msg),
|
| 644 |
|
- |
Screen::Analytics => app.analytics_status = Some(msg),
|
| 645 |
|
- |
Screen::Settings => app.settings_status = Some(msg),
|
| 646 |
|
- |
_ => app.item_status = Some(msg),
|
| 647 |
|
- |
}
|
| 648 |
|
- |
}
|
| 649 |
|
- |
DataPayload::Analytics { data } => {
|
| 650 |
|
- |
app.analytics_data = Some(data);
|
| 651 |
|
- |
app.loading = false;
|
| 652 |
|
- |
}
|
| 653 |
|
- |
DataPayload::Transactions { txs } => {
|
| 654 |
|
- |
app.transactions = txs;
|
| 655 |
|
- |
app.loading = false;
|
| 656 |
|
- |
app.selected_index = 0;
|
| 657 |
|
- |
}
|
| 658 |
|
- |
DataPayload::ExportCsv { csv, row_count } => {
|
| 659 |
|
- |
app.analytics_status =
|
| 660 |
|
- |
Some(format!("Exported {} rows ({} bytes)", row_count, csv.len()));
|
| 661 |
|
- |
}
|
| 662 |
|
- |
DataPayload::Settings { keys, storage } => {
|
| 663 |
|
- |
app.ssh_keys = keys;
|
| 664 |
|
- |
if let Some(s) = storage {
|
| 665 |
|
- |
app.storage_info = Some(s);
|
| 666 |
|
- |
}
|
| 667 |
|
- |
app.loading = false;
|
| 668 |
|
- |
app.selected_index = 0;
|
| 669 |
|
- |
}
|
| 670 |
|
- |
DataPayload::ItemTags { tags } => {
|
| 671 |
|
- |
app.item_tags = tags;
|
| 672 |
|
- |
}
|
| 673 |
|
- |
DataPayload::TagSearchResults { results } => {
|
| 674 |
|
- |
app.tag_search_results = results;
|
| 675 |
|
- |
app.tag_searching = false;
|
| 676 |
|
- |
}
|
| 677 |
|
- |
DataPayload::CollectionsList { collections: c } => {
|
| 678 |
|
- |
app.collections = c;
|
| 679 |
|
- |
app.loading = false;
|
| 680 |
|
- |
app.selected_index = 0;
|
| 681 |
|
- |
}
|
| 682 |
|
- |
DataPayload::TiersList { tiers: t } => {
|
| 683 |
|
- |
app.tiers = t;
|
| 684 |
|
- |
app.loading = false;
|
| 685 |
|
- |
app.selected_index = 0;
|
| 686 |
|
- |
}
|
| 687 |
|
- |
DataPayload::BulkActionComplete { message } => {
|
| 688 |
|
- |
app.item_status = Some(message);
|
| 689 |
|
- |
app.selected_items.clear();
|
| 690 |
|
- |
// Reload project items
|
| 691 |
|
- |
if let Screen::Project(pidx) = &screen {
|
| 692 |
|
- |
if let Some(p) = app.projects.get(*pidx) {
|
| 693 |
|
- |
app.loading = true;
|
| 694 |
|
- |
let api = api.clone();
|
| 695 |
|
- |
let project_id = p.id.clone();
|
| 696 |
|
- |
let user_id = app.user.user_id.clone();
|
| 697 |
|
- |
let tx = tx.clone();
|
| 698 |
|
- |
tokio::spawn(async move {
|
| 699 |
|
- |
load_project_items(&api, &project_id, &user_id, &tx).await;
|
| 700 |
|
- |
});
|
| 701 |
|
- |
}
|
| 702 |
|
- |
}
|
| 703 |
|
- |
}
|
| 704 |
|
- |
DataPayload::ProjectReload { project_idx } => {
|
| 705 |
|
- |
if let Some(p) = app.projects.get(project_idx) {
|
| 706 |
|
- |
app.loading = true;
|
| 707 |
|
- |
let api = api.clone();
|
| 708 |
|
- |
let project_id = p.id.clone();
|
| 709 |
|
- |
let user_id = app.user.user_id.clone();
|
| 710 |
|
- |
let tx = tx.clone();
|
| 711 |
|
- |
tokio::spawn(async move {
|
| 712 |
|
- |
load_project_items(&api, &project_id, &user_id, &tx).await;
|
| 713 |
|
- |
});
|
| 714 |
|
- |
}
|
| 715 |
|
- |
}
|
| 716 |
|
- |
DataPayload::PublishResult {
|
| 717 |
|
- |
filename,
|
| 718 |
|
- |
success,
|
| 719 |
|
- |
error,
|
| 720 |
|
- |
} => {
|
| 721 |
|
- |
app.publishing = false;
|
| 722 |
|
- |
if success {
|
| 723 |
|
- |
app.upload_status = Some(format!("Published {}", filename));
|
| 724 |
|
- |
// Reload staged files
|
| 725 |
|
- |
let staging_dir = staging_dir.clone();
|
| 726 |
|
- |
let api = api.clone();
|
| 727 |
|
- |
let user_id = app.user.user_id.clone();
|
| 728 |
|
- |
let tx = tx.clone();
|
| 729 |
|
- |
tokio::spawn(async move {
|
| 730 |
|
- |
load_staged_files(&staging_dir, &api, &user_id, &tx).await;
|
| 731 |
|
- |
});
|
| 732 |
|
- |
} else {
|
| 733 |
|
- |
app.upload_status = Some(format!(
|
| 734 |
|
- |
"Error: {}",
|
| 735 |
|
- |
error.unwrap_or_else(|| "unknown error".to_string())
|
| 736 |
|
- |
));
|
| 737 |
|
- |
}
|
| 738 |
|
- |
}
|
| 739 |
|
- |
},
|
| 740 |
|
- |
}
|
| 741 |
|
- |
|
| 742 |
|
- |
// Re-render after every event
|
| 743 |
|
- |
if let Err(e) = terminal.draw(|frame| match &screen {
|
| 744 |
|
- |
Screen::Home => home::render(frame, &app),
|
| 745 |
|
- |
Screen::Project(idx) => {
|
| 746 |
|
- |
if let Some(p) = app.projects.get(*idx) {
|
| 747 |
|
- |
project::render(frame, &app, p);
|
| 748 |
|
- |
} else {
|
| 749 |
|
- |
home::render(frame, &app);
|
| 750 |
|
- |
}
|
| 751 |
|
- |
}
|
| 752 |
|
- |
Screen::Upload => upload::render(frame, &app),
|
| 753 |
|
- |
Screen::Item(..) => item::render(frame, &app),
|
| 754 |
|
- |
Screen::Blog(..) => blog::render(frame, &app),
|
| 755 |
|
- |
Screen::Promo => promo::render(frame, &app),
|
| 756 |
|
- |
Screen::Keys(..) => keys::render(frame, &app),
|
| 757 |
|
- |
Screen::Analytics => analytics::render(frame, &app),
|
| 758 |
|
- |
Screen::Settings => settings::render(frame, &app),
|
| 759 |
|
- |
Screen::Collections => collections::render(frame, &app),
|
| 760 |
|
- |
Screen::Tiers(..) => tiers::render(frame, &app),
|
| 761 |
|
- |
}) {
|
| 762 |
|
- |
tracing::error!(error = ?e, "render failed");
|
| 763 |
|
- |
cleanup(&mut terminal);
|
| 764 |
|
- |
return;
|
| 765 |
|
- |
}
|
| 766 |
|
- |
}
|
| 767 |
|
- |
// Event loop ended (channel dropped)
|
| 768 |
|
- |
cleanup(&mut terminal);
|
| 769 |
|
- |
});
|
| 770 |
|
- |
|
| 771 |
|
- |
Ok(handle)
|
| 772 |
|
- |
}
|
| 773 |
|
- |
|
| 774 |
388 |
|
fn format_edit_prompt(field: EditField, buffer: &str) -> String {
|
| 775 |
389 |
|
let field_name = match field {
|
| 776 |
390 |
|
EditField::Title => "Title",
|