Skip to main content

rusticity_term/
finder.rs

1//! Resource finder state and cancellation. AWS work never runs in input handlers.
2use crate::app::App;
3use crate::keymap::{Action, Mode};
4use rusticity_core::finder::{
5    self, Resource, ResourceKind, SearchCache, SearchQuery, SearchUpdate, SharedSearchCache,
6    RESULT_LIMIT,
7};
8use std::{
9    collections::HashSet,
10    sync::Arc,
11    time::{Duration, Instant},
12};
13use tokio::{
14    sync::{mpsc, Mutex},
15    task::JoinHandle,
16};
17
18pub mod navigation;
19mod related;
20const RELATED_TTL: Duration = Duration::from_secs(60);
21
22struct RelatedCache {
23    source: Resource,
24    at: Instant,
25    rows: Vec<Resource>,
26}
27
28const DEBOUNCE: Duration = Duration::from_millis(250);
29
30struct RunningSearch {
31    task: JoinHandle<()>,
32    receiver: mpsc::Receiver<SearchUpdate>,
33    generation: u64,
34}
35impl Drop for RunningSearch {
36    fn drop(&mut self) {
37        self.task.abort();
38    }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum Focus {
43    #[default]
44    Filter,
45    Table,
46}
47
48impl Focus {
49    fn toggle(self) -> Self {
50        match self {
51            Self::Filter => Self::Table,
52            Self::Table => Self::Filter,
53        }
54    }
55}
56
57pub struct State {
58    pub focus: Focus,
59    pub input: String,
60    pub rows: Vec<Resource>,
61    pub selected: usize,
62    pub searching: bool,
63    pub opening: bool,
64    pub limited: bool,
65    pub provider: String,
66    pub warnings: Vec<String>,
67    pub pending_open: Option<Resource>,
68    pub related_source: Option<Resource>,
69    related_rows: Vec<Resource>,
70    related_cache: Vec<RelatedCache>,
71    query: Option<SearchQuery>,
72    changed: Instant,
73    generation: u64,
74    started: bool,
75    running: Option<RunningSearch>,
76    scope: String,
77    cache: SharedSearchCache,
78    seen: HashSet<(finder::ResourceKind, String, String)>,
79}
80
81impl Default for State {
82    fn default() -> Self {
83        Self {
84            focus: Focus::Filter,
85            input: String::new(),
86            rows: vec![],
87            selected: 0,
88            searching: false,
89            opening: false,
90            limited: false,
91            provider: String::new(),
92            warnings: vec![],
93            pending_open: None,
94            related_source: None,
95            related_rows: vec![],
96            related_cache: vec![],
97            query: None,
98            changed: Instant::now(),
99            generation: 0,
100            started: false,
101            running: None,
102            scope: String::new(),
103            cache: Arc::new(Mutex::new(SearchCache::default())),
104            seen: HashSet::new(),
105        }
106    }
107}
108
109impl State {
110    fn scope(app: &App) -> String {
111        format!(
112            "{}|{}|{}|{}",
113            app.profile, app.config.region, app.config.account_id, app.config.role_arn
114        )
115    }
116    pub fn cancel(&mut self) {
117        self.running = None;
118        self.searching = false;
119        self.generation = self.generation.wrapping_add(1);
120    }
121    fn changed(&mut self) {
122        if self.related_source.is_some() {
123            self.filter_related();
124            return;
125        }
126        self.cancel();
127        self.rows.clear();
128        self.seen.clear();
129        self.selected = 0;
130        self.warnings.clear();
131        self.provider.clear();
132        self.limited = false;
133        self.changed = Instant::now();
134        self.started = false;
135        self.query = match SearchQuery::parse(&self.input) {
136            Ok(query) => Some(query),
137            Err(error) => {
138                self.warnings.push(error.to_string());
139                None
140            }
141        };
142    }
143    pub fn related_available(app: &App) -> bool {
144        related::selected(app).is_some()
145    }
146
147    fn filter_related(&mut self) {
148        let selected = self.rows.get(self.selected).cloned();
149        self.query = SearchQuery::parse(&self.input).ok();
150        self.rows = self
151            .related_rows
152            .iter()
153            .filter(|row| self.query.as_ref().is_some_and(|query| query.matches(row)))
154            .cloned()
155            .collect();
156        self.selected = selected
157            .and_then(|selected| self.rows.iter().position(|row| *row == selected))
158            .unwrap_or(0);
159    }
160
161    fn restart_related(&mut self) {
162        self.cancel();
163        self.rows.clear();
164        self.related_rows.clear();
165        self.seen.clear();
166        self.warnings.clear();
167        self.limited = false;
168        self.selected = 0;
169        self.started = false;
170        self.provider = "Explicit resource references".into();
171        self.filter_related();
172    }
173
174    fn accept(&mut self, generation: u64, update: SearchUpdate) {
175        if generation != self.generation {
176            return;
177        }
178        match update {
179            SearchUpdate::Provider(provider) => self.provider = provider.into(),
180            SearchUpdate::Results(rows) => {
181                for row in rows {
182                    if self.related_source.as_ref().is_some_and(|source| {
183                        !source.arn.is_empty() && source.arn == row.arn && source.kind == row.kind
184                    }) {
185                        continue;
186                    }
187                    let count = if self.related_source.is_some() {
188                        self.related_rows.len()
189                    } else {
190                        self.rows.len()
191                    };
192                    if count >= RESULT_LIMIT {
193                        self.limited = true;
194                        break;
195                    }
196                    if self.seen.insert((
197                        row.kind,
198                        if self.related_source.is_some() && !row.arn.is_empty() {
199                            row.arn.clone()
200                        } else {
201                            row.id.clone()
202                        },
203                        row.region.clone(),
204                    )) {
205                        if self.related_source.is_some() {
206                            self.related_rows.push(row);
207                        } else {
208                            self.rows.push(row);
209                        }
210                    }
211                }
212                if self.related_source.is_some() {
213                    self.filter_related();
214                }
215                // Append order keeps keyboard selection stable as later sources finish.
216            }
217            SearchUpdate::Warning(warning) => self.warnings.push(warning),
218            SearchUpdate::Finished { limited } => {
219                self.searching = false;
220                self.limited |= limited;
221                if let Some(source) = self
222                    .related_source
223                    .clone()
224                    .filter(|_| self.warnings.is_empty() && !self.limited)
225                {
226                    self.related_cache
227                        .retain(|entry| entry.source != source && entry.at.elapsed() < RELATED_TTL);
228                    if self.related_cache.len() >= 16 {
229                        self.related_cache.remove(0);
230                    }
231                    self.related_cache.push(RelatedCache {
232                        source,
233                        at: Instant::now(),
234                        rows: self.related_rows.clone(),
235                    });
236                }
237            }
238        }
239    }
240    pub fn message(&self) -> String {
241        if self.opening {
242            return "Opening resource...".into();
243        }
244        if !self.warnings.is_empty() {
245            return format!(
246                "{} warning(s): {}",
247                self.warnings.len(),
248                self.warnings.join(" | ")
249            );
250        }
251        if self.related_source.is_some() {
252            if let Err(error) = SearchQuery::parse(&self.input) {
253                return error.to_string();
254            }
255            return format!(
256                "{} matches / {} linked resources{}{} — {}",
257                self.rows.len(),
258                self.related_rows.len(),
259                if self.searching || !self.started {
260                    " ..."
261                } else {
262                    ""
263                },
264                if self.limited {
265                    " — partial results"
266                } else {
267                    ""
268                },
269                self.provider
270            );
271        }
272        if !self.query.as_ref().is_some_and(SearchQuery::ready) {
273            return String::new();
274        }
275        let suffix = if self.searching || !self.started {
276            " ..."
277        } else {
278            ""
279        };
280        let limited = if self.limited {
281            " — partial results; refine your search"
282        } else {
283            ""
284        };
285        format!(
286            "{} results{} — {}{}",
287            self.rows.len(),
288            suffix,
289            self.provider,
290            limited
291        )
292    }
293}
294
295pub fn open(app: &mut App) {
296    let scope = State::scope(app);
297    if app.resource_finder.scope != scope {
298        app.resource_finder = State::default();
299        app.resource_finder.scope = scope;
300    }
301    if app.resource_finder.related_source.take().is_some() {
302        app.resource_finder.input.clear();
303        app.resource_finder.related_rows.clear();
304    }
305    app.resource_finder.focus = Focus::Filter;
306    app.resource_finder.opening = false;
307    app.resource_finder.pending_open = None;
308    app.resource_finder.changed();
309    app.pending_key = None;
310    app.mode = Mode::ResourceFinder;
311}
312
313pub fn open_related(app: &mut App) {
314    let Some(source) = related::selected(app) else {
315        return;
316    };
317    let scope = State::scope(app);
318    if app.resource_finder.scope != scope {
319        app.resource_finder = State::default();
320        app.resource_finder.scope = scope;
321    }
322    let finder = &mut app.resource_finder;
323    finder.related_source = Some(source.clone());
324    finder.input.clear();
325    finder.opening = false;
326    finder.pending_open = None;
327    finder.focus = Focus::Filter;
328    finder.restart_related();
329    if let Some(entry) = finder
330        .related_cache
331        .iter()
332        .find(|entry| entry.source == source && entry.at.elapsed() < RELATED_TTL)
333    {
334        finder.related_rows = entry.rows.clone();
335        finder.started = true;
336        finder.provider = "Explicit resource references (60s cache)".into();
337        finder.filter_related();
338    }
339    app.pending_key = None;
340    app.mode = Mode::ResourceFinder;
341}
342
343pub fn handle_action(app: &mut App, action: &Action) -> bool {
344    if *action == Action::OpenRelatedResources {
345        open_related(app);
346        return true;
347    }
348    if *action == Action::OpenResourceFinder {
349        open(app);
350        return true;
351    }
352    if app.mode != Mode::ResourceFinder {
353        return false;
354    }
355    let finder = &mut app.resource_finder;
356    match action {
357        Action::CloseMenu | Action::GoBack => {
358            finder.cancel();
359            finder.pending_open = None;
360            finder.opening = false;
361            app.mode = Mode::Normal;
362        }
363        Action::NextFilterFocus | Action::PrevFilterFocus => finder.focus = finder.focus.toggle(),
364        Action::StartFilter | Action::EnterFilterMode => finder.focus = Focus::Filter,
365        Action::FilterInput('i' | '/') if finder.focus == Focus::Table => {
366            finder.focus = Focus::Filter
367        }
368        Action::FilterInput(c) if finder.focus == Focus::Filter => {
369            finder.input.push(*c);
370            finder.changed();
371        }
372        Action::FilterBackspace if finder.focus == Focus::Filter => {
373            finder.input.pop();
374            finder.changed();
375        }
376        Action::DeleteWord if finder.focus == Focus::Filter => {
377            finder.input = finder.input.trim_end().to_string();
378            let end = finder
379                .input
380                .rfind(char::is_whitespace)
381                .map(|i| i + 1)
382                .unwrap_or(0);
383            finder.input.truncate(end);
384            finder.changed();
385        }
386        Action::NextItem if finder.focus == Focus::Table => {
387            finder.selected = finder
388                .selected
389                .saturating_add(1)
390                .min(finder.rows.len().saturating_sub(1))
391        }
392        Action::PrevItem if finder.focus == Focus::Table => {
393            finder.selected = finder.selected.saturating_sub(1)
394        }
395        Action::PageDown if finder.focus == Focus::Table => {
396            finder.selected = finder
397                .selected
398                .saturating_add(10)
399                .min(finder.rows.len().saturating_sub(1))
400        }
401        Action::PageUp if finder.focus == Focus::Table => {
402            finder.selected = finder.selected.saturating_sub(10)
403        }
404        Action::Refresh => {
405            if finder.related_source.is_some() {
406                finder
407                    .related_cache
408                    .retain(|entry| Some(&entry.source) != finder.related_source.as_ref());
409                finder.restart_related();
410            } else {
411                finder.cache = Arc::new(Mutex::new(SearchCache::default()));
412                finder.changed();
413            }
414        }
415        Action::Select if finder.focus == Focus::Filter => finder.focus = Focus::Table,
416        Action::FilterInput('y')
417            if finder.focus == Focus::Table && finder.related_source.is_some() =>
418        {
419            if let Some(resource) = finder.rows.get(finder.selected) {
420                app.clipboard.request_text(if resource.arn.is_empty() {
421                    &resource.id
422                } else {
423                    &resource.arn
424                });
425            }
426        }
427        Action::Select => {
428            if let Some(resource) = finder.rows.get(finder.selected).cloned() {
429                if resource.kind == ResourceKind::Reference {
430                    let warning =
431                        "This resource type has no Rusticity view yet; y copies its reference"
432                            .to_string();
433                    if !finder.warnings.contains(&warning) {
434                        finder.warnings.push(warning);
435                    }
436                    return true;
437                }
438                if let Err(error) = navigation::validate_scope(&app.config, &resource) {
439                    let warning = error.to_string();
440                    if !finder.warnings.contains(&warning) {
441                        finder.warnings.push(warning);
442                    }
443                    return true;
444                }
445                finder.cancel();
446                finder.pending_open = Some(resource);
447                finder.opening = true;
448            }
449        }
450        _ => {}
451    }
452    true
453}
454
455/// Called from the event loop, including idle ticks. No AWS call is awaited here.
456pub fn poll(app: &mut App) {
457    if app.mode != Mode::ResourceFinder {
458        app.resource_finder.cancel();
459        return;
460    }
461    let scope = State::scope(app);
462    if scope != app.resource_finder.scope {
463        if app.resource_finder.related_source.is_some() {
464            app.resource_finder = State::default();
465            app.mode = Mode::Normal;
466            return;
467        }
468        open(app);
469    }
470    let finder = &mut app.resource_finder;
471    if finder.opening {
472        return;
473    }
474    if !finder.started && finder.related_source.is_some() {
475        let source = finder.related_source.clone().expect("related source");
476        let (tx, receiver) = mpsc::channel(32);
477        let task = tokio::spawn(rusticity_core::related::discover(
478            app.config.clone(),
479            source,
480            tx,
481        ));
482        finder.running = Some(RunningSearch {
483            task,
484            receiver,
485            generation: finder.generation,
486        });
487        finder.started = true;
488        finder.searching = true;
489    } else if !finder.started && finder.changed.elapsed() >= DEBOUNCE {
490        if let Some(query) = finder.query.clone().filter(SearchQuery::ready) {
491            let (tx, receiver) = mpsc::channel(32);
492            let config = app.config.clone();
493            let cache = finder.cache.clone();
494            let task = tokio::spawn(finder::search(config, query, cache, tx));
495            finder.running = Some(RunningSearch {
496                task,
497                receiver,
498                generation: finder.generation,
499            });
500            finder.started = true;
501            finder.searching = true;
502        }
503    }
504    // Bound drain work so a busy producer can't monopolize the UI thread.
505    for _ in 0..32 {
506        let Some(running) = finder.running.as_mut() else {
507            break;
508        };
509        match running.receiver.try_recv() {
510            Ok(update) => {
511                let generation = running.generation;
512                finder.accept(generation, update);
513            }
514            Err(mpsc::error::TryRecvError::Empty) => break,
515            Err(mpsc::error::TryRecvError::Disconnected) => {
516                if finder.searching {
517                    finder
518                        .warnings
519                        .push("Search ended unexpectedly; Ctrl+R retries".into());
520                }
521                finder.searching = false;
522                finder.running = None;
523                break;
524            }
525        }
526    }
527}
528
529#[cfg(test)]
530mod tests;