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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core Logic
|
|
||||||
granc_core = { path = "../granc-core" }
|
granc_core = { path = "../granc-core" }
|
||||||
|
teatui = { version = "0.4.0", features = ["tokio"] }
|
||||||
# Architecture
|
ratatui = "0.30"
|
||||||
teatui = "0.1"
|
crossterm = "0.29"
|
||||||
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"
|
serde_json = "1.0"
|
||||||
directories = "5.0"
|
thiserror = "2.0"
|
||||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
|
||||||
once_cell = "1.19"
|
|
||||||
|
|
||||||
# Error Handling
|
|
||||||
color-eyre = "0.6"
|
|
||||||
anyhow = "1.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;
|
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
|
||||||
mod effects;
|
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
|
||||||
mod globals;
|
use ratatui::{
|
||||||
mod model;
|
buffer::Buffer,
|
||||||
mod msg;
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
mod update;
|
style::{Color, Modifier, Style, Stylize},
|
||||||
mod view;
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, Paragraph, Widget, Wrap},
|
||||||
use model::Model;
|
};
|
||||||
|
use serde_json::json;
|
||||||
#[tokio::main]
|
use teatui::{ProgramError, update::Update};
|
||||||
async fn main() -> color_eyre::Result<()> {
|
|
||||||
color_eyre::install()?;
|
|
||||||
|
|
||||||
// Initialize global Tokio handle for effects thread
|
|
||||||
globals::init_handle();
|
|
||||||
|
|
||||||
let initial_model = Model::default();
|
|
||||||
|
|
||||||
|
fn main() -> Result<(), ProgramError<Model, Message, Effect>> {
|
||||||
teatui::start(
|
teatui::start(
|
||||||
initial_model,
|
|| {
|
||||||
update::update,
|
(
|
||||||
view::view,
|
Model::default(),
|
||||||
effects::handle_effect,
|
Some(Effect::Connect("http://localhost:50051".to_string())),
|
||||||
)?;
|
)
|
||||||
|
},
|
||||||
Ok(())
|
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