feat: it almost works!

This commit is contained in:
JasterV 2026-01-29 15:19:51 +01:00
parent 09304342e7
commit 2598bd4093
10 changed files with 1172 additions and 1554 deletions

1166
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"] }

View file

@ -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(())
}
}

View file

@ -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())),
}
}
}
}

View file

@ -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")
}

View file

@ -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);
}
} }

View file

@ -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))
}
}

View file

@ -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,
}
}
}

View file

@ -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(),
}
}

View file

@ -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);
}