1use crate::apig::api::{self, RestApi};
2use crate::apig::resource::Resource as ApigResource;
3use crate::apig::route::Route;
4use crate::app::App;
5use crate::common::{
6 filter_by_field, render_pagination_text, CyclicEnum, InputFocus, SortDirection,
7};
8use crate::keymap::Mode;
9use crate::table::TableState;
10use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
11use crate::ui::render_tabs;
12use crate::ui::table::{expanded_from_columns, render_table, Column as TableColumn, TableConfig};
13use crate::ui::tree::TreeItem;
14use ratatui::prelude::*;
15use ratatui::widgets::Row;
16use std::collections::{HashMap, HashSet};
17
18pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
19
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum ApiDetailTab {
22 Routes,
23 }
33
34impl CyclicEnum for ApiDetailTab {
35 const ALL: &'static [Self] = &[
36 Self::Routes,
37 ];
47
48 fn next(&self) -> Self {
49 match self {
50 Self::Routes => Self::Routes,
51 }
61 }
62
63 fn prev(&self) -> Self {
64 match self {
65 Self::Routes => Self::Routes,
66 }
76 }
77}
78
79impl ApiDetailTab {
80 pub fn as_str(&self) -> &'static str {
81 match self {
82 Self::Routes => "Routes",
83 }
93 }
94
95 pub fn as_str_for_api(&self, protocol_type: &str) -> &'static str {
96 if protocol_type.to_uppercase() == "REST" {
97 match self {
98 Self::Routes => "Resources",
99 }
109 } else {
110 self.as_str()
111 }
112 }
113
114 pub fn all() -> Vec<Self> {
115 vec![
116 Self::Routes,
117 ]
127 }
128}
129
130pub struct State {
131 pub apis: TableState<RestApi>,
132 pub input_focus: InputFocus,
133 pub current_api: Option<RestApi>,
134 pub detail_tab: ApiDetailTab,
135 pub routes: TableState<Route>,
136 pub expanded_routes: HashSet<String>,
137 pub route_children: HashMap<String, Vec<Route>>,
138 pub resources: TableState<ApigResource>,
139 pub expanded_resources: HashSet<String>,
140 pub resource_children: HashMap<String, Vec<ApigResource>>,
141 pub route_filter: String,
142}
143
144impl Default for State {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150impl State {
151 pub fn new() -> Self {
152 Self {
153 apis: TableState::new(),
154 input_focus: InputFocus::Filter,
155 current_api: None,
156 detail_tab: ApiDetailTab::Routes,
157 routes: TableState::new(),
158 expanded_routes: HashSet::new(),
159 route_children: HashMap::new(),
160 resources: TableState::new(),
161 expanded_resources: HashSet::new(),
162 resource_children: HashMap::new(),
163 route_filter: String::new(),
164 }
165 }
166}
167
168pub fn filtered_apis(app: &App) -> Vec<&RestApi> {
169 filter_by_field(
170 &app.apig_state.apis.items,
171 &app.apig_state.apis.filter,
172 |a| &a.name,
173 )
174}
175
176pub fn render_apis(frame: &mut Frame, app: &App, area: Rect) {
177 if app.apig_state.current_api.is_some() {
179 render_api_detail(frame, app, area);
180 return;
181 }
182
183 let chunks = Layout::default()
184 .direction(Direction::Vertical)
185 .constraints([
186 Constraint::Length(3), Constraint::Min(0), ])
189 .split(area);
190
191 let filtered: Vec<_> = filtered_apis(app);
193 let filtered_count = filtered.len();
194
195 let page_size = app.apig_state.apis.page_size.value();
196 let total_pages = filtered_count.div_ceil(page_size);
197 let current_page = app.apig_state.apis.selected / page_size;
198 let pagination = render_pagination_text(current_page, total_pages);
199
200 render_simple_filter(
202 frame,
203 chunks[0],
204 SimpleFilterConfig {
205 filter_text: &app.apig_state.apis.filter,
206 placeholder: "Find APIs",
207 pagination: &pagination,
208 mode: app.mode,
209 is_input_focused: app.apig_state.input_focus == InputFocus::Filter,
210 is_pagination_focused: app.apig_state.input_focus == InputFocus::Pagination,
211 },
212 );
213
214 let start_idx = current_page * page_size;
216 let end_idx = (start_idx + page_size).min(filtered.len());
217 let paginated: Vec<_> = filtered[start_idx..end_idx].to_vec();
218
219 let title = format!("APIs ({}) ", filtered_count);
220
221 let columns: Vec<Box<dyn TableColumn<RestApi>>> = app
223 .apig_api_visible_column_ids
224 .iter()
225 .filter_map(|col_id| {
226 api::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn TableColumn<RestApi>>)
227 })
228 .collect();
229
230 let expanded_index = app.apig_state.apis.expanded_item.and_then(|idx| {
231 if idx >= start_idx && idx < end_idx {
232 Some(idx - start_idx)
233 } else {
234 None
235 }
236 });
237
238 let config = TableConfig {
239 items: paginated,
240 selected_index: app.apig_state.apis.selected % page_size,
241 expanded_index,
242 columns: &columns,
243 sort_column: "Name",
244 sort_direction: SortDirection::Asc,
245 title,
246 area: chunks[1],
247 get_expanded_content: Some(Box::new(|api: &RestApi| {
248 expanded_from_columns(&columns, api)
249 })),
250 is_active: app.mode != Mode::FilterInput,
251 };
252
253 render_table(frame, config);
254}
255
256fn render_api_detail(frame: &mut Frame, app: &App, area: Rect) {
257 if let Some(api) = &app.apig_state.current_api {
258 let chunks = Layout::default()
259 .direction(Direction::Vertical)
260 .constraints([
261 Constraint::Length(1), Constraint::Min(0), ])
264 .split(area);
265
266 let tabs: Vec<(&str, ApiDetailTab)> = ApiDetailTab::all()
268 .iter()
269 .map(|t| (t.as_str_for_api(&api.protocol_type), *t))
270 .collect();
271 render_tabs(frame, chunks[0], &tabs, &app.apig_state.detail_tab);
272
273 let inner = chunks[1];
275
276 let (_status_text, _status_color) = crate::apig::format_status(&api.status);
278
279 match app.apig_state.detail_tab {
281 ApiDetailTab::Routes => {
282 let route_chunks = Layout::default()
284 .direction(ratatui::layout::Direction::Vertical)
285 .constraints([
286 Constraint::Length(3), Constraint::Min(0), ])
289 .split(inner);
290
291 render_simple_filter(
293 frame,
294 route_chunks[0],
295 SimpleFilterConfig {
296 filter_text: &app.apig_state.route_filter,
297 placeholder: "Search",
298 pagination: "",
299 mode: app.mode,
300 is_input_focused: app.mode == Mode::FilterInput
301 && app.apig_state.current_api.is_some()
302 && app.apig_state.detail_tab == ApiDetailTab::Routes,
303 is_pagination_focused: false,
304 },
305 );
306
307 let content_area = route_chunks[1];
308
309 if api.protocol_type.to_uppercase() == "REST" {
311 if app.apig_state.resources.loading {
312 let loading = ratatui::widgets::Paragraph::new("Loading resources...")
313 .style(Style::default().fg(Color::Yellow));
314 frame.render_widget(loading, content_area);
315 } else if app.apig_state.resources.items.is_empty() {
316 let empty = ratatui::widgets::Paragraph::new("No resources found")
317 .style(Style::default().fg(Color::Yellow));
318 frame.render_widget(empty, content_area);
319 } else {
320 use crate::ui::tree::TreeRenderer;
321
322 let (filtered_items, filtered_children) = filter_tree_items(
324 &app.apig_state.resources.items,
325 &app.apig_state.resource_children,
326 &app.apig_state.route_filter,
327 );
328
329 let renderer = TreeRenderer::<ApigResource>::new(
330 &filtered_items,
331 &app.apig_state.expanded_resources,
332 &filtered_children,
333 app.apig_state.resources.selected,
334 0,
335 );
336
337 use crate::apig::resource::Column as ResourceColumn;
338 use crate::ui::table::Column as TableColumn;
339
340 let mut visible_columns: Vec<ResourceColumn> = vec![ResourceColumn::Path];
342 visible_columns.extend(
343 app.apig_resource_visible_column_ids
344 .iter()
345 .filter_map(|id| ResourceColumn::from_id(id))
346 .filter(|col| *col != ResourceColumn::Path),
347 );
348
349 let tree_rows = renderer.render(|resource, tree_prefix| {
350 visible_columns
351 .iter()
352 .enumerate()
353 .map(|(i, col)| {
354 if i == 0 {
355 let (text, _) = col.render(resource);
356 ratatui::widgets::Cell::from(format!(
357 "{}{}",
358 tree_prefix, text
359 ))
360 } else {
361 let (text, _) = col.render(resource);
362 ratatui::widgets::Cell::from(text)
363 }
364 })
365 .collect()
366 });
367
368 let headers: Vec<&str> =
369 visible_columns.iter().map(|col| col.name()).collect();
370
371 let widths: Vec<Constraint> = visible_columns
372 .iter()
373 .map(|col| Constraint::Length(col.width()))
374 .collect();
375
376 let rows: Vec<Row> = tree_rows
377 .into_iter()
378 .map(|(cells, style)| Row::new(cells).style(style))
379 .collect();
380
381 crate::ui::table::render_tree_table(
382 frame,
383 content_area,
384 format!("{} - Resources ", api.name),
385 headers,
386 rows,
387 widths,
388 app.mode != Mode::FilterInput,
389 );
390 }
391 } else {
392 if app.apig_state.routes.loading {
394 let message = vec![Line::from(""), Line::from("Loading routes...")];
395 let paragraph = ratatui::widgets::Paragraph::new(message)
396 .style(Style::default().fg(Color::Yellow));
397 frame.render_widget(paragraph, content_area);
398 } else if app.apig_state.routes.items.is_empty() {
399 let message = vec![
400 Line::from(""),
401 Line::from("No routes found for this API."),
402 Line::from(""),
403 Line::from(format!("API ID: {}", api.id)),
404 Line::from(format!("Protocol: {}", api.protocol_type)),
405 ];
406 let paragraph = ratatui::widgets::Paragraph::new(message)
407 .style(Style::default().fg(Color::Yellow));
408 frame.render_widget(paragraph, content_area);
409 } else {
410 use crate::ui::tree::TreeRenderer;
411 use ratatui::widgets::Row;
412
413 let (filtered_items, filtered_children) = filter_tree_items(
415 &app.apig_state.routes.items,
416 &app.apig_state.route_children,
417 &app.apig_state.route_filter,
418 );
419
420 let renderer = TreeRenderer::new(
421 &filtered_items,
422 &app.apig_state.expanded_routes,
423 &filtered_children,
424 app.apig_state.routes.selected,
425 0,
426 );
427
428 use crate::apig::route::Column as RouteColumn;
429 use crate::ui::table::Column as TableColumn;
430
431 let mut visible_columns: Vec<RouteColumn> = vec![RouteColumn::RouteKey];
433 visible_columns.extend(
434 app.apig_route_visible_column_ids
435 .iter()
436 .filter_map(|id| RouteColumn::from_id(id))
437 .filter(|col| *col != RouteColumn::RouteKey), );
439
440 let tree_rows = renderer.render(|route, tree_prefix| {
441 visible_columns
442 .iter()
443 .enumerate()
444 .map(|(i, col)| {
445 if i == 0 {
446 let (text, _) = col.render(route);
448 ratatui::widgets::Cell::from(format!(
449 "{}{}",
450 tree_prefix, text
451 ))
452 } else {
453 let (text, _) = col.render(route);
454 ratatui::widgets::Cell::from(text)
455 }
456 })
457 .collect()
458 });
459
460 let headers: Vec<&str> =
461 visible_columns.iter().map(|col| col.name()).collect();
462
463 let widths: Vec<Constraint> = visible_columns
464 .iter()
465 .map(|col| Constraint::Length(col.width()))
466 .collect();
467
468 let rows: Vec<Row> = tree_rows
469 .into_iter()
470 .map(|(cells, style)| Row::new(cells).style(style))
471 .collect();
472
473 crate::ui::table::render_tree_table(
474 frame,
475 content_area,
476 format!("{} - Routes ", api.name),
477 headers,
478 rows,
479 widths,
480 app.mode != Mode::FilterInput,
481 );
482 }
483 }
484 }
485 }
486 }
487}
488
489pub async fn load_routes(app: &mut App, api_id: &str) -> anyhow::Result<()> {
490 let client = rusticity_core::apig::ApiGatewayClient::new(app.config.clone());
491 let routes = client.list_routes(api_id).await?;
492
493 let route_items: Vec<Route> = routes
494 .into_iter()
495 .map(|r| Route {
496 route_id: r.route_id.clone(),
497 route_key: r.route_key.clone(),
498 target: r.target,
499 authorization_type: r.authorization_type,
500 api_key_required: r.api_key_required,
501 display_name: r.route_key,
502 arn: r.arn,
503 })
504 .collect();
505
506 let (root_routes, children_map) = build_route_hierarchy(route_items);
508
509 app.apig_state.routes.items = root_routes;
510 app.apig_state.route_children = children_map;
511
512 Ok(())
513}
514
515pub fn build_route_hierarchy(routes: Vec<Route>) -> (Vec<Route>, HashMap<String, Vec<Route>>) {
517 let mut all_routes: HashMap<String, Route> = HashMap::new();
518 let mut children_map: HashMap<String, Vec<Route>> = HashMap::new();
519
520 for route in routes {
522 let route_key = &route.route_key;
523
524 if route_key.contains(' ') {
526 let parts: Vec<&str> = route_key.split_whitespace().collect();
527 if parts.len() == 2 {
528 let method = parts[0];
529 let path = parts[1];
530
531 if !all_routes.contains_key(path) {
533 let display = if path == "/" {
534 "/".to_string()
535 } else {
536 path.rsplit('/')
537 .next()
538 .map(|s| format!("/{}", s))
539 .unwrap_or(path.to_string())
540 };
541
542 all_routes.insert(
543 path.to_string(),
544 Route {
545 route_id: String::new(), route_key: path.to_string(),
547 target: String::new(),
548 authorization_type: String::new(),
549 api_key_required: false,
550 display_name: display,
551 arn: String::new(), },
553 );
554 }
555
556 let method_route = Route {
558 route_id: route.route_id.clone(),
559 route_key: method.to_string(),
560 target: route.target.clone(),
561 authorization_type: route.authorization_type.clone(),
562 api_key_required: route.api_key_required,
563 display_name: method.to_string(),
564 arn: route.arn.clone(),
565 };
566
567 children_map
568 .entry(path.to_string())
569 .or_default()
570 .push(method_route);
571 continue;
572 }
573 }
574
575 let route_key_clone = route_key.clone();
577 let mut route_with_display = route;
578 if route_with_display.display_name.is_empty() {
579 route_with_display.display_name = if route_key_clone == "/" {
580 "/".to_string()
581 } else if route_key_clone.starts_with('/') {
582 route_key_clone
583 .rsplit('/')
584 .next()
585 .map(|s| format!("/{}", s))
586 .unwrap_or(route_key_clone.clone())
587 } else {
588 route_key_clone.clone()
589 };
590 }
591 all_routes.insert(route_key_clone, route_with_display);
592 }
593
594 let route_keys: Vec<String> = all_routes.keys().cloned().collect();
596
597 for route_key in &route_keys {
598 if route_key.starts_with('$') {
600 continue;
601 }
602
603 let path = if route_key.contains(' ') {
605 route_key.split_whitespace().nth(1).unwrap_or(route_key)
607 } else {
608 route_key.as_str()
610 };
611
612 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
614
615 for i in 1..segments.len() {
617 let parent_segments = &segments[..i];
618 let parent_path = format!("/{}", parent_segments.join("/"));
619
620 if !all_routes.contains_key(&parent_path) {
622 let display = format!("/{}", segments[i - 1]);
623 all_routes.insert(
624 parent_path.clone(),
625 Route {
626 route_id: String::new(), route_key: parent_path.clone(),
628 target: String::new(),
629 authorization_type: String::new(),
630 api_key_required: false,
631 display_name: display,
632 arn: String::new(),
633 },
634 );
635 }
636 }
637 }
638
639 let all_route_keys: Vec<String> = all_routes.keys().cloned().collect();
641
642 for route_key in &all_route_keys {
643 if route_key.starts_with('$') {
645 continue;
646 }
647
648 let path = if route_key.contains(' ') {
650 route_key.split_whitespace().nth(1).unwrap_or(route_key)
651 } else {
652 route_key.as_str()
653 };
654
655 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
656
657 if segments.len() > 1 {
658 let parent_segments = &segments[..segments.len() - 1];
660 let parent_path = format!("/{}", parent_segments.join("/"));
661
662 if let Some(route) = all_routes.get(route_key) {
664 children_map
665 .entry(parent_path)
666 .or_default()
667 .push(route.clone());
668 }
669 }
670 }
671
672 let mut root_routes = Vec::new();
674 for (route_key, route) in &all_routes {
675 if route_key.starts_with('$') {
676 root_routes.push(route.clone());
677 continue;
678 }
679
680 let path = if route_key.contains(' ') {
681 route_key.split_whitespace().nth(1).unwrap_or(route_key)
682 } else {
683 route_key.as_str()
684 };
685
686 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
687 if segments.len() == 1 {
688 root_routes.push(route.clone());
689 }
690 }
691
692 (root_routes, children_map)
693}
694
695pub async fn load_resources(app: &mut App, api_id: &str) -> anyhow::Result<()> {
696 let client = rusticity_core::apig::ApiGatewayClient::new(app.config.clone());
697 let resources = client.list_resources(api_id).await?;
698
699 let resource_items: Vec<ApigResource> = resources
700 .into_iter()
701 .map(|r| ApigResource {
702 id: r.id,
703 path: r.path,
704 parent_id: r.parent_id,
705 methods: r.methods,
706 display_name: r.display_name,
707 arn: r.arn,
708 })
709 .collect();
710
711 let (root_resources, children_map) = build_resource_hierarchy(resource_items);
712
713 app.apig_state.resources.items = root_resources;
714 app.apig_state.resource_children = children_map;
715
716 Ok(())
717}
718
719pub fn build_resource_hierarchy(
720 mut resources: Vec<ApigResource>,
721) -> (Vec<ApigResource>, HashMap<String, Vec<ApigResource>>) {
722 let mut children_map: HashMap<String, Vec<ApigResource>> = HashMap::new();
723
724 let parent_ids: std::collections::HashSet<String> = resources
726 .iter()
727 .filter_map(|r| r.parent_id.clone())
728 .collect();
729
730 for resource in &mut resources {
731 if parent_ids.contains(&resource.id) && resource.methods.is_empty() {
732 resource.methods.push("_has_children".to_string());
733 }
734 }
735
736 let mut root_resources = Vec::new();
737
738 for resource in resources {
739 let mut method_children = Vec::new();
741 for method in &resource.methods {
742 if method != "_has_children" {
743 method_children.push(ApigResource {
744 id: format!("{}#{}", resource.id, method),
745 path: method.clone(),
746 parent_id: Some(resource.id.clone()),
747 methods: vec![],
748 display_name: method.clone(),
749 arn: String::new(), });
751 }
752 }
753
754 if !method_children.is_empty() {
755 children_map.insert(resource.id.clone(), method_children);
756 }
757
758 if let Some(parent_id) = &resource.parent_id {
760 children_map
761 .entry(parent_id.clone())
762 .or_default()
763 .push(resource);
764 } else {
765 root_resources.push(resource);
766 }
767 }
768
769 (root_resources, children_map)
770}
771
772pub fn filter_tree_items<T: TreeItem + Clone>(
773 items: &[T],
774 children_map: &HashMap<String, Vec<T>>,
775 filter: &str,
776) -> (Vec<T>, HashMap<String, Vec<T>>) {
777 if filter.is_empty() {
778 return (items.to_vec(), children_map.clone());
779 }
780
781 let filter_lower = filter.to_lowercase();
782 let mut filtered_items = Vec::new();
783 let mut filtered_children = HashMap::new();
784
785 for item in items {
786 if matches_filter(item, children_map, &filter_lower, &mut filtered_children) {
787 filtered_items.push(item.clone());
788 }
789 }
790
791 (filtered_items, filtered_children)
792}
793
794fn matches_filter<T: TreeItem + Clone>(
795 item: &T,
796 children_map: &HashMap<String, Vec<T>>,
797 filter: &str,
798 filtered_children: &mut HashMap<String, Vec<T>>,
799) -> bool {
800 if item.display_name().to_lowercase().contains(filter) {
802 if let Some(children) = children_map.get(item.id()) {
804 filtered_children.insert(item.id().to_string(), children.clone());
805 }
806 return true;
807 }
808
809 if let Some(children) = children_map.get(item.id()) {
811 let mut matching_children = Vec::new();
812 for child in children {
813 if matches_filter(child, children_map, filter, filtered_children) {
814 matching_children.push(child.clone());
815 }
816 }
817
818 if !matching_children.is_empty() {
819 filtered_children.insert(item.id().to_string(), matching_children);
820 return true;
821 }
822 }
823
824 false
825}