1use 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 }
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
455pub 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 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;