Skip to main content

rusticity_term/ui/
apig.rs

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    // Authorization,
24    // Integrations,
25    // Cors,
26    // Reimport,
27    // Export,
28    // Stages,
29    // Metrics,
30    // Logging,
31    // Throttling,
32}
33
34impl CyclicEnum for ApiDetailTab {
35    const ALL: &'static [Self] = &[
36        Self::Routes,
37        // Self::Authorization,
38        // Self::Integrations,
39        // Self::Cors,
40        // Self::Reimport,
41        // Self::Export,
42        // Self::Stages,
43        // Self::Metrics,
44        // Self::Logging,
45        // Self::Throttling,
46    ];
47
48    fn next(&self) -> Self {
49        match self {
50            Self::Routes => Self::Routes,
51            // Self::Authorization => Self::Integrations,
52            // Self::Integrations => Self::Cors,
53            // Self::Cors => Self::Reimport,
54            // Self::Reimport => Self::Export,
55            // Self::Export => Self::Stages,
56            // Self::Stages => Self::Metrics,
57            // Self::Metrics => Self::Logging,
58            // Self::Logging => Self::Throttling,
59            // Self::Throttling => Self::Routes,
60        }
61    }
62
63    fn prev(&self) -> Self {
64        match self {
65            Self::Routes => Self::Routes,
66            // Self::Authorization => Self::Routes,
67            // Self::Integrations => Self::Authorization,
68            // Self::Cors => Self::Integrations,
69            // Self::Reimport => Self::Cors,
70            // Self::Export => Self::Reimport,
71            // Self::Stages => Self::Export,
72            // Self::Metrics => Self::Stages,
73            // Self::Logging => Self::Metrics,
74            // Self::Throttling => Self::Logging,
75        }
76    }
77}
78
79impl ApiDetailTab {
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            Self::Routes => "Routes",
83            // Self::Authorization => "Authorization",
84            // Self::Integrations => "Integrations",
85            // Self::Cors => "CORS",
86            // Self::Reimport => "Reimport",
87            // Self::Export => "Export",
88            // Self::Stages => "Stages",
89            // Self::Metrics => "Metrics",
90            // Self::Logging => "Logging",
91            // Self::Throttling => "Throttling",
92        }
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                // Self::Authorization => "Authorizers",
100                // Self::Integrations => "Gateway Responses",
101                // Self::Cors => "Models",
102                // Self::Reimport => "Resource Policy",
103                // Self::Export => "Documentation",
104                // Self::Stages => "Stages",
105                // Self::Metrics => "Dashboard",
106                // Self::Logging => "Settings",
107                // Self::Throttling => "API Keys",
108            }
109        } else {
110            self.as_str()
111        }
112    }
113
114    pub fn all() -> Vec<Self> {
115        vec![
116            Self::Routes,
117            // Self::Authorization,
118            // Self::Integrations,
119            // Self::Cors,
120            // Self::Reimport,
121            // Self::Export,
122            // Self::Stages,
123            // Self::Metrics,
124            // Self::Logging,
125            // Self::Throttling,
126        ]
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    // Check if we're in detail view
178    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), // Filter
187            Constraint::Min(0),    // Table
188        ])
189        .split(area);
190
191    // Calculate pagination
192    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    // Filter
201    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    // Apply pagination
215    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    // Define columns
222    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), // Tabs
262                Constraint::Min(0),    // Content
263            ])
264            .split(area);
265
266        // Render tabs with API-type-specific names
267        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        // Render tab content
274        let inner = chunks[1];
275
276        // Format status with color
277        let (_status_text, _status_color) = crate::apig::format_status(&api.status);
278
279        // Show API details based on selected tab
280        match app.apig_state.detail_tab {
281            ApiDetailTab::Routes => {
282                // Split for filter and content
283                let route_chunks = Layout::default()
284                    .direction(ratatui::layout::Direction::Vertical)
285                    .constraints([
286                        Constraint::Length(3), // Filter
287                        Constraint::Min(0),    // Content
288                    ])
289                    .split(inner);
290
291                // Render filter
292                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                // Check if this is a REST API (v1) - they have resources, not routes
310                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                        // Apply filter
323                        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                        // Get visible columns - always include first column (Resource)
341                        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                    // Render routes tree for HTTP/WebSocket APIs
393                    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                        // Apply filter
414                        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                        // Get visible columns - always include first column (Route)
432                        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), // Skip if already added
438                        );
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                                        // First column gets tree prefix
447                                        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    // Build hierarchy from paths
507    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
515/// Build hierarchical structure from flat route list based on path segments
516pub 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    // First pass: collect all actual routes and split method from path
521    for route in routes {
522        let route_key = &route.route_key;
523
524        // Check if route has HTTP method prefix
525        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                // Store the path node (without method)
532                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(), // Virtual parent has no ID
546                            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(), // Virtual parent has no ARN
552                        },
553                    );
554                }
555
556                // Create method child node
557                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        // Regular route without method prefix - set display_name
576        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    // Second pass: create all virtual parent nodes
595    let route_keys: Vec<String> = all_routes.keys().cloned().collect();
596
597    for route_key in &route_keys {
598        // Special routes (WebSocket) have no parents
599        if route_key.starts_with('$') {
600            continue;
601        }
602
603        // Extract path from route key (may include HTTP method like "GET /path")
604        let path = if route_key.contains(' ') {
605            // Format: "GET /path" - extract path part
606            route_key.split_whitespace().nth(1).unwrap_or(route_key)
607        } else {
608            // Format: "/path"
609            route_key.as_str()
610        };
611
612        // Split path into segments
613        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
614
615        // Create all parent levels
616        for i in 1..segments.len() {
617            let parent_segments = &segments[..i];
618            let parent_path = format!("/{}", parent_segments.join("/"));
619
620            // Create virtual parent if it doesn't exist
621            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(), // Virtual parent has no ID
627                        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    // Third pass: build parent-child relationships
640    let all_route_keys: Vec<String> = all_routes.keys().cloned().collect();
641
642    for route_key in &all_route_keys {
643        // Special routes (WebSocket) have no parents
644        if route_key.starts_with('$') {
645            continue;
646        }
647
648        // Extract path from route key
649        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            // Parent is all segments except last
659            let parent_segments = &segments[..segments.len() - 1];
660            let parent_path = format!("/{}", parent_segments.join("/"));
661
662            // Add this route as child of parent
663            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    // Collect root routes (single segment or WebSocket)
673    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    // Mark resources that have children
725    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        // Create method children for this resource (skip marker)
740        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(), // Method children don't have ARNs
750                });
751            }
752        }
753
754        if !method_children.is_empty() {
755            children_map.insert(resource.id.clone(), method_children);
756        }
757
758        // Add resource to parent or root
759        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    // Check if item itself matches
801    if item.display_name().to_lowercase().contains(filter) {
802        // Include all children
803        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    // Check if any child matches
810    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}