mirror of
https://codeberg.org/JasterV/granc.git
synced 2026-04-26 18:40:05 +00:00
feat: it almost works!
This commit is contained in:
parent
09304342e7
commit
2598bd4093
10 changed files with 1172 additions and 1554 deletions
1166
Cargo.lock
generated
1166
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,25 +4,12 @@ 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"] }
|
||||
teatui = { version = "0.4.0", features = ["tokio"] }
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
serde_json = "1.0"
|
||||
directories = "5.0"
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
once_cell = "1.19"
|
||||
|
||||
# Error Handling
|
||||
color-eyre = "0.6"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
colored = "3.1"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -1,28 +1,459 @@
|
|||
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();
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Widget, Wrap},
|
||||
};
|
||||
use serde_json::json;
|
||||
use teatui::{ProgramError, update::Update};
|
||||
|
||||
fn main() -> Result<(), ProgramError<Model, Message, Effect>> {
|
||||
teatui::start(
|
||||
initial_model,
|
||||
update::update,
|
||||
view::view,
|
||||
effects::handle_effect,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
|| {
|
||||
(
|
||||
Model::default(),
|
||||
Some(Effect::Connect("http://localhost:50051".to_string())),
|
||||
)
|
||||
},
|
||||
update,
|
||||
view,
|
||||
run_effects,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Model: Functional Application State ---
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Pane {
|
||||
Services,
|
||||
Methods,
|
||||
Payload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Model {
|
||||
pub uri: String,
|
||||
pub services: Vec<String>,
|
||||
pub selected_service_idx: usize,
|
||||
pub methods: Vec<String>,
|
||||
pub selected_method_idx: usize,
|
||||
pub method_definition: String,
|
||||
pub json_payload: String,
|
||||
pub response_log: String,
|
||||
pub active_pane: Pane,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Model {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
uri: "http://localhost:50051".into(),
|
||||
services: vec![],
|
||||
selected_service_idx: 0,
|
||||
methods: vec![],
|
||||
selected_method_idx: 0,
|
||||
method_definition: "Select a method to see schema...".into(),
|
||||
json_payload: json!({ "name": "Granc" }).to_string(),
|
||||
response_log: "Ready to inspect server.".into(),
|
||||
active_pane: Pane::Services,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Messages & Effects ---
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Message {
|
||||
SetServices(Vec<String>),
|
||||
SetMethods(Vec<String>),
|
||||
SetMethodDefinition(String),
|
||||
SetResponse(String),
|
||||
SetError(String),
|
||||
MoveDown,
|
||||
MoveUp,
|
||||
SwitchPane,
|
||||
ExecuteCall,
|
||||
Tick,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Effect {
|
||||
Connect(String),
|
||||
FetchMethods(String),
|
||||
DescribeSymbol(String),
|
||||
Call(String, String, String),
|
||||
}
|
||||
|
||||
impl From<crossterm::event::Event> for Message {
|
||||
fn from(value: Event) -> Self {
|
||||
match value {
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('q') | KeyCode::Esc,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => Self::Exit,
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => Self::SwitchPane,
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Down | KeyCode::Char('j'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => Self::MoveDown,
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Up | KeyCode::Char('k'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => Self::MoveUp,
|
||||
Event::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => Self::ExecuteCall,
|
||||
_ => Self::Tick,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update: Pure Logic ---
|
||||
|
||||
pub fn update(mut model: Model, msg: Message) -> Update<Model, Effect> {
|
||||
match msg {
|
||||
Message::Exit => Update::Exit,
|
||||
Message::SwitchPane => {
|
||||
model.active_pane = match model.active_pane {
|
||||
Pane::Services => Pane::Methods,
|
||||
Pane::Methods => Pane::Payload,
|
||||
Pane::Payload => Pane::Services,
|
||||
};
|
||||
Update::Next(model, None)
|
||||
}
|
||||
Message::MoveDown => match model.active_pane {
|
||||
Pane::Services if !model.services.is_empty() => {
|
||||
model.selected_service_idx =
|
||||
(model.selected_service_idx + 1) % model.services.len();
|
||||
Update::Next(
|
||||
model.clone(),
|
||||
Some(Effect::FetchMethods(
|
||||
model.services[model.selected_service_idx].clone(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
Pane::Methods if !model.methods.is_empty() => {
|
||||
model.selected_method_idx = (model.selected_method_idx + 1) % model.methods.len();
|
||||
let symbol = format!(
|
||||
"{}.{}",
|
||||
model.services[model.selected_service_idx],
|
||||
model.methods[model.selected_method_idx]
|
||||
);
|
||||
Update::Next(model, Some(Effect::DescribeSymbol(symbol)))
|
||||
}
|
||||
_ => Update::Next(model, None),
|
||||
},
|
||||
Message::MoveUp => match model.active_pane {
|
||||
Pane::Services if !model.services.is_empty() => {
|
||||
model.selected_service_idx = if model.selected_service_idx == 0 {
|
||||
model.services.len() - 1
|
||||
} else {
|
||||
model.selected_service_idx - 1
|
||||
};
|
||||
Update::Next(
|
||||
model.clone(),
|
||||
Some(Effect::FetchMethods(
|
||||
model.services[model.selected_service_idx].clone(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
Pane::Methods if !model.methods.is_empty() => {
|
||||
model.selected_method_idx = if model.selected_method_idx == 0 {
|
||||
model.methods.len() - 1
|
||||
} else {
|
||||
model.selected_method_idx - 1
|
||||
};
|
||||
let symbol = format!(
|
||||
"{}.{}",
|
||||
model.services[model.selected_service_idx],
|
||||
model.methods[model.selected_method_idx]
|
||||
);
|
||||
Update::Next(model, Some(Effect::DescribeSymbol(symbol)))
|
||||
}
|
||||
_ => Update::Next(model, None),
|
||||
},
|
||||
Message::SetServices(svcs) => {
|
||||
model.services = svcs;
|
||||
Update::Next(model, None)
|
||||
}
|
||||
Message::SetMethods(meths) => {
|
||||
model.methods = meths;
|
||||
Update::Next(model, None)
|
||||
}
|
||||
Message::SetMethodDefinition(def) => {
|
||||
model.method_definition = def;
|
||||
Update::Next(model, None)
|
||||
}
|
||||
Message::ExecuteCall => {
|
||||
if model.services.is_empty() || model.methods.is_empty() {
|
||||
return Update::Next(model, None);
|
||||
}
|
||||
let svc = model.services[model.selected_service_idx].clone();
|
||||
let meth = model.methods[model.selected_method_idx].clone();
|
||||
Update::Next(
|
||||
model.clone(),
|
||||
Some(Effect::Call(svc, meth, model.json_payload.clone())),
|
||||
)
|
||||
}
|
||||
Message::SetResponse(res) => {
|
||||
model.response_log = res;
|
||||
Update::Next(model, None)
|
||||
}
|
||||
Message::SetError(err) => {
|
||||
model.error = Some(err);
|
||||
Update::Next(model, None)
|
||||
}
|
||||
_ => Update::Next(model, None),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Effects: Async Isolation with local Tokio Reactor ---
|
||||
|
||||
pub async fn run_effects(model: Model, effect: Effect) -> Option<Message> {
|
||||
let uri = model.uri.clone();
|
||||
|
||||
match effect {
|
||||
Effect::Connect(url) => match GrancClient::connect(&url).await {
|
||||
Ok(mut client) => Some(Message::SetServices(
|
||||
client.list_services().await.unwrap_or_default(),
|
||||
)),
|
||||
Err(e) => Some(Message::SetError(e.to_string())),
|
||||
},
|
||||
Effect::FetchMethods(svc_name) => {
|
||||
let mut client = GrancClient::connect(&uri).await.ok()?;
|
||||
if let Ok(Descriptor::ServiceDescriptor(sd)) =
|
||||
client.get_descriptor_by_symbol(&svc_name).await
|
||||
{
|
||||
return Some(Message::SetMethods(
|
||||
sd.methods().map(|m| m.name().to_string()).collect(),
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
Effect::DescribeSymbol(symbol) => {
|
||||
let mut client = GrancClient::connect(&uri).await.ok()?;
|
||||
if let Ok(descriptor) = client.get_descriptor_by_symbol(&symbol).await {
|
||||
let def = match descriptor {
|
||||
Descriptor::MessageDescriptor(m) => {
|
||||
format!("message {} {{ // ... }}", m.name())
|
||||
}
|
||||
Descriptor::ServiceDescriptor(s) => {
|
||||
format!("service {} {{ // ... }}", s.name())
|
||||
}
|
||||
Descriptor::EnumDescriptor(e) => format!("enum {} {{ // ... }}", e.name()),
|
||||
};
|
||||
return Some(Message::SetMethodDefinition(def));
|
||||
}
|
||||
None
|
||||
}
|
||||
Effect::Call(svc, meth, payload) => {
|
||||
let mut client = GrancClient::connect(&uri).await.ok()?;
|
||||
let body = serde_json::from_str(&payload).unwrap_or(json!({}));
|
||||
let req = DynamicRequest {
|
||||
service: svc,
|
||||
method: meth,
|
||||
body,
|
||||
headers: vec![],
|
||||
};
|
||||
match client.dynamic(req).await {
|
||||
Ok(DynamicResponse::Unary(Ok(v))) => Some(Message::SetResponse(v.to_string())),
|
||||
Ok(DynamicResponse::Unary(Err(s))) => {
|
||||
Some(Message::SetError(s.message().to_string()))
|
||||
}
|
||||
_ => Some(Message::SetError("Call failed".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- View: Fully Featured Multi-Pane Widget ---
|
||||
|
||||
pub fn view(model: Model) -> AppWidget {
|
||||
AppWidget { model }
|
||||
}
|
||||
|
||||
pub struct AppWidget {
|
||||
model: Model,
|
||||
}
|
||||
|
||||
impl Widget for AppWidget {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let active_style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style = Style::default().fg(Color::DarkGray);
|
||||
|
||||
// Layout: [Header] (3) -> [Content] (rest) -> [Footer] (1)
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// 1. Header
|
||||
let header_content = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
" 🦀 GRANC WORKSPACE ",
|
||||
Style::default().bg(Color::Blue).fg(Color::White).bold(),
|
||||
),
|
||||
Span::raw(" Server: "),
|
||||
Span::styled(self.model.uri.clone(), Color::Green).underlined(),
|
||||
]),
|
||||
Line::from(
|
||||
" (TAB: Switch Pane | Arrows/JK: Select | ENTER: Call) "
|
||||
.italic()
|
||||
.dark_gray(),
|
||||
),
|
||||
];
|
||||
Paragraph::new(header_content).render(root[0], buf);
|
||||
|
||||
// 2. Content Split: Sidebar (30%) and Main (70%)
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(root[1]);
|
||||
|
||||
// 2a. Sidebar Vertical: Services (50%) and Methods (50%)
|
||||
let sidebar = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(body[0]);
|
||||
|
||||
// Services List
|
||||
let svc_items: Vec<ListItem> = self
|
||||
.model
|
||||
.services
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let style = if i == self.model.selected_service_idx {
|
||||
Style::default().fg(Color::Yellow).bold()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(format!(" • {}", s)).style(style)
|
||||
})
|
||||
.collect();
|
||||
List::new(svc_items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" 1. SERVICES ")
|
||||
.border_style(if self.model.active_pane == Pane::Services {
|
||||
active_style
|
||||
} else {
|
||||
normal_style
|
||||
}),
|
||||
)
|
||||
.render(sidebar[0], buf);
|
||||
|
||||
// Methods List
|
||||
let meth_items: Vec<ListItem> = self
|
||||
.model
|
||||
.methods
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let style = if i == self.model.selected_method_idx {
|
||||
Style::default().fg(Color::Cyan).bold()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(format!(" ƒ {}", m)).style(style)
|
||||
})
|
||||
.collect();
|
||||
List::new(meth_items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" 2. METHODS ")
|
||||
.border_style(if self.model.active_pane == Pane::Methods {
|
||||
active_style
|
||||
} else {
|
||||
normal_style
|
||||
}),
|
||||
)
|
||||
.render(sidebar[1], buf);
|
||||
|
||||
// 2b. Main Vertical: Definition (40%) and Payload/Response (60%)
|
||||
let main = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(body[1]);
|
||||
|
||||
Paragraph::new(self.model.method_definition.clone())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" PROTO DEFINITION ")
|
||||
.italic()
|
||||
.cyan(),
|
||||
)
|
||||
.render(main[0], buf);
|
||||
|
||||
// Payload & Response Horizontal Split
|
||||
let execution = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(main[1]);
|
||||
|
||||
Paragraph::new(self.model.json_payload.clone())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" 3. PAYLOAD ")
|
||||
.border_style(if self.model.active_pane == Pane::Payload {
|
||||
active_style
|
||||
} else {
|
||||
normal_style
|
||||
}),
|
||||
)
|
||||
.render(execution[0], buf);
|
||||
|
||||
let resp_style = if self.model.error.is_some() {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::LightGreen
|
||||
};
|
||||
Paragraph::new(
|
||||
self.model
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or(self.model.response_log.clone()),
|
||||
)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" RESPONSE LOG "),
|
||||
)
|
||||
.style(Style::default().fg(resp_style))
|
||||
.wrap(Wrap { trim: true })
|
||||
.render(execution[1], buf);
|
||||
|
||||
// 3. Footer
|
||||
Paragraph::new(
|
||||
" Press 'q' to Quit | Granc Workspace v0.1.0 "
|
||||
.on_dark_gray()
|
||||
.white(),
|
||||
)
|
||||
.render(root[2], buf);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
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