mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
playing around with a TUI
This commit is contained in:
parent
c05b4fe865
commit
09304342e7
11 changed files with 2061 additions and 18 deletions
967
Cargo.lock
generated
967
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["granc", "granc-core", "echo-service"]
|
||||
members = ["granc", "granc-core", "granc-tui", "echo-service"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
28
granc-tui/Cargo.toml
Normal file
28
granc-tui/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "granc-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# Core Logic
|
||||
granc_core = { path = "../granc-core" }
|
||||
|
||||
# Architecture
|
||||
teatui = "0.1"
|
||||
ratatui = "0.29.0"
|
||||
crossterm = "0.29.0"
|
||||
tui-textarea = "0.7.0"
|
||||
|
||||
# Async Runtime
|
||||
tokio = { version = "1.43", features = ["full"] }
|
||||
|
||||
# Data & Config
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
directories = "5.0"
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
once_cell = "1.19"
|
||||
|
||||
# Error Handling
|
||||
color-eyre = "0.6"
|
||||
anyhow = "1.0"
|
||||
68
granc-tui/src/config.rs
Normal file
68
granc-tui/src/config.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use anyhow::{Context, Result};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
|
||||
pub struct AppConfig {
|
||||
pub projects: Vec<Project>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct Project {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub connection: ConnectionConfig,
|
||||
pub saved_requests: Vec<SavedRequest>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum ConnectionConfig {
|
||||
Reflection { url: String },
|
||||
File { url: String, path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct SavedRequest {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
pub body: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct ConfigManager {
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfigManager {
|
||||
pub fn new() -> Result<Self> {
|
||||
let proj_dirs = ProjectDirs::from("com", "granc", "granc-tui")
|
||||
.context("Could not determine config directory")?;
|
||||
let config_dir = proj_dirs.config_dir();
|
||||
fs::create_dir_all(config_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
config_path: config_dir.join("config.json"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<AppConfig> {
|
||||
if !self.config_path.exists() {
|
||||
return Ok(AppConfig::default());
|
||||
}
|
||||
let content = fs::read_to_string(&self.config_path)?;
|
||||
let config = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self, config: &AppConfig) -> Result<()> {
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&self.config_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
243
granc-tui/src/effects.rs
Normal file
243
granc-tui/src/effects.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use crate::config::{ConfigManager, ConnectionConfig, Project};
|
||||
use crate::globals;
|
||||
use crate::model::{MethodData, Model};
|
||||
use crate::msg::Msg;
|
||||
use color_eyre::Result;
|
||||
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Effect {
|
||||
LoadConfigFromDisk,
|
||||
SaveConfigToDisk(crate::config::AppConfig),
|
||||
FetchServices(Project),
|
||||
FetchMethods {
|
||||
project: Project,
|
||||
service: String,
|
||||
},
|
||||
ExecuteCall {
|
||||
project: Project,
|
||||
service: String,
|
||||
method: String,
|
||||
body: String,
|
||||
headers: Vec<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn handle_effect(_model: &Model, effect: Effect) -> Result<Option<Msg>> {
|
||||
match effect {
|
||||
Effect::LoadConfigFromDisk => {
|
||||
let manager = ConfigManager::new().map_err(|e| color_eyre::eyre::eyre!(e))?;
|
||||
match manager.load() {
|
||||
Ok(cfg) => Ok(Some(Msg::ConfigLoaded(Ok(cfg)))),
|
||||
Err(e) => Ok(Some(Msg::ConfigLoaded(Err(e.to_string())))),
|
||||
}
|
||||
}
|
||||
|
||||
Effect::SaveConfigToDisk(cfg) => {
|
||||
if let Ok(manager) = ConfigManager::new() {
|
||||
let _ = manager.save(&cfg);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
Effect::FetchServices(proj) => {
|
||||
let handle = globals::get_handle();
|
||||
let project_id = proj.id;
|
||||
let result = handle.block_on(async move { fetch_services_async(&proj).await });
|
||||
|
||||
match result {
|
||||
Ok(services) => Ok(Some(Msg::ServicesFetched {
|
||||
project_id,
|
||||
services,
|
||||
})),
|
||||
Err(e) => Ok(Some(Msg::CallResponse(Err(format!("Fetch failed: {}", e))))),
|
||||
}
|
||||
}
|
||||
|
||||
Effect::FetchMethods { project, service } => {
|
||||
let handle = globals::get_handle();
|
||||
let service_name = service.clone();
|
||||
let result =
|
||||
handle.block_on(async move { fetch_methods_async(&project, &service_name).await });
|
||||
|
||||
match result {
|
||||
Ok(methods) => Ok(Some(Msg::MethodsFetched { service, methods })),
|
||||
Err(e) => Ok(Some(Msg::CallResponse(Err(format!(
|
||||
"Fetch methods failed: {}",
|
||||
e
|
||||
))))),
|
||||
}
|
||||
}
|
||||
|
||||
Effect::ExecuteCall {
|
||||
project,
|
||||
service,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
} => {
|
||||
let handle = globals::get_handle();
|
||||
let result = handle.block_on(async move {
|
||||
execute_call_async(&project, service, method, body, headers).await
|
||||
});
|
||||
|
||||
Ok(Some(Msg::CallResponse(result)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Async Handlers ---
|
||||
|
||||
async fn fetch_services_async(proj: &Project) -> std::result::Result<Vec<String>, String> {
|
||||
match &proj.connection {
|
||||
ConnectionConfig::Reflection { url } => {
|
||||
let mut client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
|
||||
client.list_services().await.map_err(|e| e.to_string())
|
||||
}
|
||||
ConnectionConfig::File { url, path } => {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
if let Ok(c) = GrancClient::connect(url).await {
|
||||
if let Ok(fc) = c.with_file_descriptor(bytes.clone()) {
|
||||
return Ok(fc.list_services());
|
||||
}
|
||||
}
|
||||
let client = GrancClient::offline(bytes).map_err(|e| e.to_string())?;
|
||||
Ok(client.list_services())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_methods_async(
|
||||
proj: &Project,
|
||||
service: &str,
|
||||
) -> std::result::Result<Vec<MethodData>, String> {
|
||||
fn extract(descriptor: Descriptor) -> std::result::Result<Vec<MethodData>, String> {
|
||||
match descriptor {
|
||||
Descriptor::ServiceDescriptor(sd) => {
|
||||
let methods = sd
|
||||
.methods()
|
||||
.map(|m| {
|
||||
let input_desc = m.input();
|
||||
let input = input_desc.name();
|
||||
let output_desc = m.output();
|
||||
let output = output_desc.name();
|
||||
let client_stream = if m.is_client_streaming() {
|
||||
"stream "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let server_stream = if m.is_server_streaming() {
|
||||
"stream "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
MethodData {
|
||||
name: m.name().to_string(),
|
||||
signature: format!(
|
||||
"rpc {}({}{}) returns ({}{})",
|
||||
m.name(),
|
||||
client_stream,
|
||||
input,
|
||||
server_stream,
|
||||
output
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(methods)
|
||||
}
|
||||
_ => Err("Symbol is not a service".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
match &proj.connection {
|
||||
ConnectionConfig::Reflection { url } => {
|
||||
let mut client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
|
||||
let descriptor = client
|
||||
.get_descriptor_by_symbol(service)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
extract(descriptor)
|
||||
}
|
||||
ConnectionConfig::File { url, path } => {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
if let Ok(c) = GrancClient::connect(url).await {
|
||||
// Fixed: Removed 'mut' from fc
|
||||
if let Ok(fc) = c.with_file_descriptor(bytes.clone()) {
|
||||
if let Some(d) = fc.get_descriptor_by_symbol(service) {
|
||||
return extract(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
let client = GrancClient::offline(bytes).map_err(|e| e.to_string())?;
|
||||
if let Some(d) = client.get_descriptor_by_symbol(service) {
|
||||
return extract(d);
|
||||
}
|
||||
Err("Service not found".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_call_async(
|
||||
proj: &Project,
|
||||
service: String,
|
||||
method: String,
|
||||
body: String,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> std::result::Result<String, String> {
|
||||
let json_body: serde_json::Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
|
||||
let req = DynamicRequest {
|
||||
service,
|
||||
method,
|
||||
body: json_body,
|
||||
headers,
|
||||
};
|
||||
|
||||
match &proj.connection {
|
||||
ConnectionConfig::Reflection { url } => {
|
||||
let mut client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
|
||||
let resp = client.dynamic(req).await.map_err(|e| e.to_string())?;
|
||||
format_response(resp)
|
||||
}
|
||||
ConnectionConfig::File { url, path } => {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
|
||||
let mut client = client
|
||||
.with_file_descriptor(bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client.dynamic(req).await.map_err(|e| e.to_string())?;
|
||||
format_response(resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_response(resp: DynamicResponse) -> std::result::Result<String, String> {
|
||||
match resp {
|
||||
DynamicResponse::Unary(Ok(v)) => Ok(serde_json::to_string_pretty(&v).unwrap_or_default()),
|
||||
DynamicResponse::Unary(Err(s)) => {
|
||||
Err(format!("gRPC Error: {} (Code: {})", s.message(), s.code()))
|
||||
}
|
||||
DynamicResponse::Streaming(r) => {
|
||||
let mut out = String::new();
|
||||
match r {
|
||||
Ok(msgs) => {
|
||||
for (i, msg) in msgs.into_iter().enumerate() {
|
||||
match msg {
|
||||
Ok(v) => out.push_str(&format!(
|
||||
"Msg {}:\n{}\n",
|
||||
i,
|
||||
serde_json::to_string_pretty(&v).unwrap_or_default()
|
||||
)),
|
||||
Err(s) => out.push_str(&format!("Msg {} Error: {}\n", i, s.message())),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Err(s) => Err(format!("Stream Error: {}", s.message())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
granc-tui/src/globals.rs
Normal file
15
granc-tui/src/globals.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use once_cell::sync::OnceCell;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
pub static TOKIO_HANDLE: OnceCell<Handle> = OnceCell::new();
|
||||
|
||||
pub fn init_handle() {
|
||||
let handle = Handle::current();
|
||||
TOKIO_HANDLE
|
||||
.set(handle)
|
||||
.expect("Failed to set global Tokio handle");
|
||||
}
|
||||
|
||||
pub fn get_handle() -> &'static Handle {
|
||||
TOKIO_HANDLE.get().expect("Tokio handle not initialized")
|
||||
}
|
||||
28
granc-tui/src/main.rs
Normal file
28
granc-tui/src/main.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
mod config;
|
||||
mod effects;
|
||||
mod globals;
|
||||
mod model;
|
||||
mod msg;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
use model::Model;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
// Initialize global Tokio handle for effects thread
|
||||
globals::init_handle();
|
||||
|
||||
let initial_model = Model::default();
|
||||
|
||||
teatui::start(
|
||||
initial_model,
|
||||
update::update,
|
||||
view::view,
|
||||
effects::handle_effect,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
100
granc-tui/src/model.rs
Normal file
100
granc-tui/src/model.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
use crate::config::{AppConfig, Project};
|
||||
use tui_textarea::TextArea;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Screen {
|
||||
Dashboard,
|
||||
NewProject,
|
||||
ServiceBrowser,
|
||||
MethodBrowser,
|
||||
MethodView,
|
||||
ResponseView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Focus {
|
||||
Body,
|
||||
HeaderKey(usize),
|
||||
HeaderValue(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MethodData {
|
||||
pub name: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeaderPair {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Model {
|
||||
pub screen: Screen,
|
||||
pub config: AppConfig,
|
||||
|
||||
// Global Input Buffer (New Project / URL)
|
||||
pub input_buffer: String,
|
||||
|
||||
// Navigation Indices
|
||||
pub project_list_idx: usize,
|
||||
pub service_list_idx: usize,
|
||||
pub method_list_idx: usize,
|
||||
|
||||
// Data
|
||||
pub selected_project_id: Option<Uuid>,
|
||||
pub services: Vec<String>,
|
||||
pub methods: Vec<MethodData>, // Updated to hold signature
|
||||
pub selected_service: Option<String>,
|
||||
pub selected_method: Option<String>,
|
||||
|
||||
// Request Editor State
|
||||
pub body_editor: TextArea<'static>,
|
||||
pub headers: Vec<HeaderPair>,
|
||||
pub focus: Focus,
|
||||
|
||||
// Results
|
||||
pub response_output: String,
|
||||
pub status_message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Model {
|
||||
fn default() -> Self {
|
||||
let mut editor = TextArea::default();
|
||||
editor.set_block(
|
||||
ratatui::widgets::Block::default()
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.title("Body (JSON)"),
|
||||
);
|
||||
editor.insert_str("{}");
|
||||
|
||||
Self {
|
||||
screen: Screen::Dashboard,
|
||||
config: AppConfig::default(),
|
||||
input_buffer: String::new(),
|
||||
project_list_idx: 0,
|
||||
service_list_idx: 0,
|
||||
method_list_idx: 0,
|
||||
selected_project_id: None,
|
||||
services: vec![],
|
||||
methods: vec![],
|
||||
selected_service: None,
|
||||
selected_method: None,
|
||||
body_editor: editor,
|
||||
headers: vec![],
|
||||
focus: Focus::Body,
|
||||
response_output: String::new(),
|
||||
status_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn current_project(&self) -> Option<&Project> {
|
||||
self.selected_project_id
|
||||
.and_then(|id| self.config.projects.iter().find(|p| p.id == id))
|
||||
}
|
||||
}
|
||||
46
granc-tui/src/msg.rs
Normal file
46
granc-tui/src/msg.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::model::MethodData;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Msg {
|
||||
// --- User Input ---
|
||||
Key(KeyEvent),
|
||||
|
||||
// --- Config Lifecycle ---
|
||||
ConfigLoaded(Result<AppConfig, String>),
|
||||
|
||||
// --- Async Results ---
|
||||
ServicesFetched {
|
||||
project_id: Uuid,
|
||||
services: Vec<String>,
|
||||
},
|
||||
MethodsFetched {
|
||||
service: String,
|
||||
methods: Vec<MethodData>,
|
||||
},
|
||||
CallResponse(Result<String, String>),
|
||||
|
||||
// --- System ---
|
||||
NoOp,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl From<Event> for Msg {
|
||||
fn from(event: Event) -> Self {
|
||||
match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
|
||||
KeyCode::Char('c')
|
||||
if key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
Msg::Exit
|
||||
}
|
||||
_ => Msg::Key(key),
|
||||
},
|
||||
_ => Msg::NoOp,
|
||||
}
|
||||
}
|
||||
}
|
||||
365
granc-tui/src/update.rs
Normal file
365
granc-tui/src/update.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
use color_eyre::Result;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use teatui::Update;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::{ConnectionConfig, Project};
|
||||
use crate::effects::Effect;
|
||||
use crate::model::{Focus, HeaderPair, Model, Screen};
|
||||
use crate::msg::Msg;
|
||||
|
||||
pub fn update(mut model: Model, msg: Msg) -> Result<Update<Model, Effect>> {
|
||||
match msg {
|
||||
Msg::Exit => Ok(Update::Exit),
|
||||
Msg::NoOp => Ok(Update::Next(model)),
|
||||
|
||||
Msg::ConfigLoaded(res) => {
|
||||
match res {
|
||||
Ok(cfg) => {
|
||||
model.config = cfg;
|
||||
model.status_message = Some("Config loaded".into());
|
||||
}
|
||||
Err(e) => model.status_message = Some(format!("Config Error: {}", e)),
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
// Handle Editor Specific Messages (Method View)
|
||||
Msg::Key(key) if model.screen == Screen::MethodView => match key.code {
|
||||
KeyCode::Esc => {
|
||||
model.screen = Screen::MethodBrowser;
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
match model.focus {
|
||||
Focus::Body => {
|
||||
if !model.headers.is_empty() {
|
||||
model.focus = Focus::HeaderKey(0);
|
||||
}
|
||||
}
|
||||
Focus::HeaderKey(i) => model.focus = Focus::HeaderValue(i),
|
||||
Focus::HeaderValue(i) => {
|
||||
if i + 1 < model.headers.len() {
|
||||
model.focus = Focus::HeaderKey(i + 1);
|
||||
} else {
|
||||
model.focus = Focus::Body;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
model.headers.push(HeaderPair {
|
||||
key: "".into(),
|
||||
value: "".into(),
|
||||
});
|
||||
model.focus = Focus::HeaderKey(model.headers.len() - 1);
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
match model.focus {
|
||||
Focus::HeaderKey(i) | Focus::HeaderValue(i) => {
|
||||
model.headers.remove(i);
|
||||
model.focus = Focus::Body;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('s')
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||
{
|
||||
execute_request(model)
|
||||
}
|
||||
_ => {
|
||||
match model.focus {
|
||||
Focus::Body => {
|
||||
model.body_editor.input(to_ratatui_key(key));
|
||||
}
|
||||
Focus::HeaderKey(i) => {
|
||||
handle_text_input(&mut model.headers[i].key, key);
|
||||
}
|
||||
Focus::HeaderValue(i) => {
|
||||
handle_text_input(&mut model.headers[i].value, key);
|
||||
}
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
},
|
||||
|
||||
Msg::Key(key) => match (model.screen.clone(), key.code) {
|
||||
// Global
|
||||
(_, KeyCode::Char('q')) => Ok(Update::Exit),
|
||||
|
||||
// --- Dashboard ---
|
||||
(Screen::Dashboard, KeyCode::Char('l')) => {
|
||||
Ok(Update::NextWithEffect(model, Effect::LoadConfigFromDisk))
|
||||
}
|
||||
(Screen::Dashboard, KeyCode::Char('n')) => {
|
||||
model.screen = Screen::NewProject;
|
||||
model.input_buffer.clear();
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::Dashboard, KeyCode::Down) => {
|
||||
if !model.config.projects.is_empty() {
|
||||
model.project_list_idx =
|
||||
(model.project_list_idx + 1) % model.config.projects.len();
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::Dashboard, KeyCode::Up) => {
|
||||
if !model.config.projects.is_empty() {
|
||||
model.project_list_idx = if model.project_list_idx == 0 {
|
||||
model.config.projects.len() - 1
|
||||
} else {
|
||||
model.project_list_idx - 1
|
||||
};
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::Dashboard, KeyCode::Enter) => {
|
||||
let project_opt = model.config.projects.get(model.project_list_idx).cloned();
|
||||
if let Some(proj) = project_opt {
|
||||
model.selected_project_id = Some(proj.id);
|
||||
model.screen = Screen::ServiceBrowser;
|
||||
model.service_list_idx = 0;
|
||||
model.status_message = Some("Fetching services...".into());
|
||||
Ok(Update::NextWithEffect(model, Effect::FetchServices(proj)))
|
||||
} else {
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
}
|
||||
|
||||
// --- New Project ---
|
||||
(Screen::NewProject, KeyCode::Enter) => {
|
||||
let new_proj = Project {
|
||||
id: Uuid::new_v4(),
|
||||
name: if model.input_buffer.is_empty() {
|
||||
"Untitled".to_string()
|
||||
} else {
|
||||
model.input_buffer.clone()
|
||||
},
|
||||
connection: ConnectionConfig::Reflection {
|
||||
url: model.input_buffer.clone(),
|
||||
},
|
||||
saved_requests: vec![],
|
||||
};
|
||||
model.config.projects.push(new_proj);
|
||||
model.screen = Screen::Dashboard;
|
||||
model.project_list_idx = model.config.projects.len() - 1;
|
||||
let effect = Effect::SaveConfigToDisk(model.config.clone());
|
||||
Ok(Update::NextWithEffect(model, effect))
|
||||
}
|
||||
(Screen::NewProject, KeyCode::Char(c)) => {
|
||||
model.input_buffer.push(c);
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::NewProject, KeyCode::Backspace) => {
|
||||
model.input_buffer.pop();
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::NewProject, KeyCode::Esc) => {
|
||||
model.screen = Screen::Dashboard;
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
// --- Service Browser ---
|
||||
(Screen::ServiceBrowser, KeyCode::Down) => {
|
||||
if !model.services.is_empty() {
|
||||
model.service_list_idx = (model.service_list_idx + 1) % model.services.len();
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::ServiceBrowser, KeyCode::Up) => {
|
||||
if !model.services.is_empty() {
|
||||
model.service_list_idx = if model.service_list_idx == 0 {
|
||||
model.services.len() - 1
|
||||
} else {
|
||||
model.service_list_idx - 1
|
||||
};
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::ServiceBrowser, KeyCode::Enter) => {
|
||||
if let (Some(svc), Some(proj)) = (
|
||||
model.services.get(model.service_list_idx).cloned(),
|
||||
model.current_project().cloned(),
|
||||
) {
|
||||
model.selected_service = Some(svc.clone());
|
||||
model.status_message = Some("Fetching methods...".into());
|
||||
Ok(Update::NextWithEffect(
|
||||
model,
|
||||
Effect::FetchMethods {
|
||||
project: proj,
|
||||
service: svc,
|
||||
},
|
||||
))
|
||||
} else {
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
}
|
||||
(Screen::ServiceBrowser, KeyCode::Esc) => {
|
||||
model.screen = Screen::Dashboard;
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
// --- Method Browser ---
|
||||
(Screen::MethodBrowser, KeyCode::Down) => {
|
||||
if !model.methods.is_empty() {
|
||||
model.method_list_idx = (model.method_list_idx + 1) % model.methods.len();
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::MethodBrowser, KeyCode::Up) => {
|
||||
if !model.methods.is_empty() {
|
||||
model.method_list_idx = if model.method_list_idx == 0 {
|
||||
model.methods.len() - 1
|
||||
} else {
|
||||
model.method_list_idx - 1
|
||||
};
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::MethodBrowser, KeyCode::Enter) => {
|
||||
if let Some(m) = model.methods.get(model.method_list_idx).cloned() {
|
||||
model.selected_method = Some(m.name);
|
||||
model.headers.clear();
|
||||
model.focus = Focus::Body;
|
||||
model.screen = Screen::MethodView;
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
(Screen::MethodBrowser, KeyCode::Esc) => {
|
||||
model.screen = Screen::ServiceBrowser;
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
// --- Response View ---
|
||||
(Screen::ResponseView, KeyCode::Esc) => {
|
||||
model.screen = Screen::MethodView;
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
_ => Ok(Update::Next(model)),
|
||||
},
|
||||
|
||||
Msg::ServicesFetched {
|
||||
project_id,
|
||||
services,
|
||||
} => {
|
||||
if model.selected_project_id == Some(project_id) {
|
||||
model.services = services;
|
||||
model.service_list_idx = 0;
|
||||
model.status_message = Some("Services loaded.".into());
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
Msg::MethodsFetched { service, methods } => {
|
||||
if model.selected_service.as_deref() == Some(&service) {
|
||||
model.methods = methods;
|
||||
model.method_list_idx = 0;
|
||||
model.screen = Screen::MethodBrowser;
|
||||
model.status_message = Some("Methods loaded.".into());
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
|
||||
Msg::CallResponse(res) => {
|
||||
model.screen = Screen::ResponseView;
|
||||
match res {
|
||||
Ok(s) => {
|
||||
model.response_output = s;
|
||||
model.status_message = Some("Call success".into());
|
||||
}
|
||||
Err(e) => {
|
||||
model.response_output = format!("Error: {}", e);
|
||||
model.status_message = Some("Call failed".into());
|
||||
}
|
||||
}
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_request(mut model: Model) -> Result<Update<Model, Effect>> {
|
||||
let execution_data = if let (Some(p), Some(s), Some(m)) = (
|
||||
model.current_project(),
|
||||
&model.selected_service,
|
||||
&model.selected_method,
|
||||
) {
|
||||
Some((p.clone(), s.clone(), m.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((p, s, m)) = execution_data {
|
||||
let headers: Vec<(String, String)> = model
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|h| !h.key.is_empty())
|
||||
.map(|h| (h.key.clone(), h.value.clone()))
|
||||
.collect();
|
||||
|
||||
let body_lines = model.body_editor.lines().to_vec();
|
||||
let body = body_lines.join("\n");
|
||||
|
||||
let effect = Effect::ExecuteCall {
|
||||
project: p,
|
||||
service: s,
|
||||
method: m,
|
||||
body,
|
||||
headers,
|
||||
};
|
||||
model.status_message = Some("Executing request...".into());
|
||||
Ok(Update::NextWithEffect(model, effect))
|
||||
} else {
|
||||
model.status_message =
|
||||
Some("Error: Missing execution context (project/service/method)".into());
|
||||
Ok(Update::Next(model))
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_text_input(target: &mut String, key: crossterm::event::KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => target.push(c),
|
||||
KeyCode::Backspace => {
|
||||
target.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_ratatui_key(key: crossterm::event::KeyEvent) -> ratatui::crossterm::event::KeyEvent {
|
||||
let code = match key.code {
|
||||
crossterm::event::KeyCode::Backspace => ratatui::crossterm::event::KeyCode::Backspace,
|
||||
crossterm::event::KeyCode::Enter => ratatui::crossterm::event::KeyCode::Enter,
|
||||
crossterm::event::KeyCode::Left => ratatui::crossterm::event::KeyCode::Left,
|
||||
crossterm::event::KeyCode::Right => ratatui::crossterm::event::KeyCode::Right,
|
||||
crossterm::event::KeyCode::Up => ratatui::crossterm::event::KeyCode::Up,
|
||||
crossterm::event::KeyCode::Down => ratatui::crossterm::event::KeyCode::Down,
|
||||
crossterm::event::KeyCode::Home => ratatui::crossterm::event::KeyCode::Home,
|
||||
crossterm::event::KeyCode::End => ratatui::crossterm::event::KeyCode::End,
|
||||
crossterm::event::KeyCode::PageUp => ratatui::crossterm::event::KeyCode::PageUp,
|
||||
crossterm::event::KeyCode::PageDown => ratatui::crossterm::event::KeyCode::PageDown,
|
||||
crossterm::event::KeyCode::Tab => ratatui::crossterm::event::KeyCode::Tab,
|
||||
crossterm::event::KeyCode::BackTab => ratatui::crossterm::event::KeyCode::BackTab,
|
||||
crossterm::event::KeyCode::Delete => ratatui::crossterm::event::KeyCode::Delete,
|
||||
crossterm::event::KeyCode::Insert => ratatui::crossterm::event::KeyCode::Insert,
|
||||
crossterm::event::KeyCode::F(n) => ratatui::crossterm::event::KeyCode::F(n),
|
||||
crossterm::event::KeyCode::Char(c) => ratatui::crossterm::event::KeyCode::Char(c),
|
||||
crossterm::event::KeyCode::Null => ratatui::crossterm::event::KeyCode::Null,
|
||||
crossterm::event::KeyCode::Esc => ratatui::crossterm::event::KeyCode::Esc,
|
||||
_ => ratatui::crossterm::event::KeyCode::Null,
|
||||
};
|
||||
|
||||
let modifiers =
|
||||
ratatui::crossterm::event::KeyModifiers::from_bits_truncate(key.modifiers.bits());
|
||||
|
||||
ratatui::crossterm::event::KeyEvent {
|
||||
code,
|
||||
modifiers,
|
||||
kind: ratatui::crossterm::event::KeyEventKind::Press,
|
||||
state: ratatui::crossterm::event::KeyEventState::empty(),
|
||||
}
|
||||
}
|
||||
217
granc-tui/src/view.rs
Normal file
217
granc-tui/src/view.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
use crate::model::{Focus, Model, Screen};
|
||||
use color_eyre::Result;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style, Stylize},
|
||||
widgets::ListState,
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, StatefulWidget, Widget, WidgetRef},
|
||||
};
|
||||
use teatui::View;
|
||||
|
||||
struct RootWidget {
|
||||
model: Model,
|
||||
}
|
||||
|
||||
impl WidgetRef for RootWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
let main_area = chunks[0];
|
||||
let status_bar = chunks[1];
|
||||
|
||||
match self.model.screen {
|
||||
Screen::Dashboard => draw_dashboard(&self.model, main_area, buf),
|
||||
Screen::NewProject => draw_new_project(&self.model, main_area, buf),
|
||||
Screen::ServiceBrowser => draw_services(&self.model, main_area, buf),
|
||||
Screen::MethodBrowser => draw_method_browser(&self.model, main_area, buf),
|
||||
Screen::MethodView => draw_method_execution(&self.model, main_area, buf),
|
||||
Screen::ResponseView => draw_response(&self.model, main_area, buf),
|
||||
}
|
||||
|
||||
let msg = self.model.status_message.as_deref().unwrap_or("Ready");
|
||||
let status_text = format!(
|
||||
" {} | Screen: {:?} | [Q] Quit | [L] Load",
|
||||
msg, self.model.screen
|
||||
);
|
||||
Paragraph::new(status_text)
|
||||
.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
.render_ref(status_bar, buf);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(model: &Model) -> Result<View> {
|
||||
Ok(View::new(RootWidget {
|
||||
model: model.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn draw_dashboard(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let items: Vec<ListItem> = model
|
||||
.config
|
||||
.projects
|
||||
.iter()
|
||||
.map(|p| ListItem::new(p.name.as_str()))
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Projects (Press 'n' for new)")
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).bold());
|
||||
|
||||
let mut state = ListState::default().with_selected(Some(model.project_list_idx));
|
||||
StatefulWidget::render(list, area, buf, &mut state);
|
||||
}
|
||||
|
||||
fn draw_new_project(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let text = Paragraph::new(format!("Server URL: {}", model.input_buffer)).block(
|
||||
Block::default()
|
||||
.title("New Project (Enter URL)")
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
text.render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn draw_services(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let items: Vec<ListItem> = model
|
||||
.services
|
||||
.iter()
|
||||
.map(|s| ListItem::new(s.as_str()))
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().title("Services").borders(Borders::ALL))
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).bold());
|
||||
|
||||
let mut state = ListState::default().with_selected(Some(model.service_list_idx));
|
||||
StatefulWidget::render(list, area, buf, &mut state);
|
||||
}
|
||||
|
||||
fn draw_method_browser(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let items: Vec<ListItem> = model
|
||||
.methods
|
||||
.iter()
|
||||
.map(|m| ListItem::new(m.signature.as_str()))
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(format!(
|
||||
"Methods of {}",
|
||||
model.selected_service.as_deref().unwrap_or("?")
|
||||
))
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).bold());
|
||||
|
||||
let mut state = ListState::default().with_selected(Some(model.method_list_idx));
|
||||
StatefulWidget::render(list, area, buf, &mut state);
|
||||
}
|
||||
|
||||
fn draw_method_execution(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Title
|
||||
Constraint::Percentage(60), // Body
|
||||
Constraint::Percentage(30), // Headers
|
||||
Constraint::Length(1), // Hint
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let title = format!(
|
||||
"Executing: {}",
|
||||
model.selected_method.as_deref().unwrap_or("?")
|
||||
);
|
||||
Paragraph::new(title).bold().render_ref(chunks[0], buf);
|
||||
|
||||
// Body Editor
|
||||
let mut editor = model.body_editor.clone();
|
||||
let body_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Request Body (JSON)");
|
||||
|
||||
if model.focus == Focus::Body {
|
||||
editor.set_style(Style::default());
|
||||
editor.set_block(body_block.border_style(Style::default().fg(Color::Yellow)));
|
||||
} else {
|
||||
editor.set_style(Style::default().fg(Color::DarkGray));
|
||||
editor.set_block(body_block);
|
||||
}
|
||||
|
||||
editor.render(chunks[1], buf);
|
||||
|
||||
// Headers
|
||||
let header_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Headers (Ctrl+H to add)");
|
||||
header_block.render_ref(chunks[2], buf);
|
||||
|
||||
let header_area = chunks[2].inner(ratatui::layout::Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
let header_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); model.headers.len()])
|
||||
.split(header_area);
|
||||
|
||||
for (i, header) in model.headers.iter().enumerate() {
|
||||
if i >= header_rows.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let row_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(45),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(45),
|
||||
])
|
||||
.split(header_rows[i]);
|
||||
|
||||
let k_style = if model.focus == Focus::HeaderKey(i) {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Paragraph::new(header.key.as_str())
|
||||
.style(k_style)
|
||||
.render_ref(row_chunks[0], buf);
|
||||
|
||||
Paragraph::new(":").render_ref(row_chunks[1], buf);
|
||||
|
||||
let v_style = if model.focus == Focus::HeaderValue(i) {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Paragraph::new(header.value.as_str())
|
||||
.style(v_style)
|
||||
.render_ref(row_chunks[2], buf);
|
||||
}
|
||||
|
||||
Paragraph::new(
|
||||
"[Tab] Cycle Focus | [Ctrl+Enter/S] Send | [Ctrl+H] Add Header | [Ctrl+D] Remove Header",
|
||||
)
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.render_ref(chunks[3], buf);
|
||||
}
|
||||
|
||||
fn draw_response(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
|
||||
let p = Paragraph::new(model.response_output.as_str()).block(
|
||||
Block::default()
|
||||
.title("Response (Esc to back)")
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
p.render_ref(area, buf);
|
||||
}
|
||||
Loading…
Reference in a new issue