1use tauri::{AppHandle, State};
18
19use crate::{
20 error::AppError,
21 events::{self, EventKind},
22 ids::{BucketId, ProfileId},
23 profiles::ProfileStoreHandle,
24 s3::{list::list_objects_flat, S3ClientPoolHandle},
25 search::{EntryRef, SearchPage, SearchRegistryHandle},
26 settings::SettingsHandle,
27};
28
29#[tauri::command]
40pub async fn search_local_filter(
41 _pane_id: String,
42 query: String,
43 entries: Vec<EntryRef>,
44) -> Result<Vec<EntryRef>, AppError> {
45 if query.is_empty() {
46 return Ok(entries);
47 }
48
49 let q = query.to_lowercase();
50 let results = entries
51 .into_iter()
52 .filter(|e| e.key.to_lowercase().contains(&q))
53 .collect();
54
55 Ok(results)
56}
57
58#[tauri::command]
78pub async fn search_prefix(
79 profile_id: ProfileId,
80 bucket: BucketId,
81 prefix: String,
82 query: String,
83 request_id: String,
84 store: State<'_, ProfileStoreHandle>,
85 pool: State<'_, S3ClientPoolHandle>,
86 search_registry: State<'_, SearchRegistryHandle>,
87 settings: State<'_, SettingsHandle>,
88 channel: AppHandle,
89) -> Result<String, AppError> {
90 let profile = {
94 let store_guard = store.inner.lock().await;
95 store_guard
96 .get(&profile_id)
97 .ok_or_else(|| AppError::NotFound {
98 resource: format!("profile:{}", profile_id.as_str()),
99 })?
100 };
101
102 if profile.validated_at.is_none() {
103 return Err(AppError::Auth {
104 reason: "profile_not_validated_in_session".to_string(),
105 });
106 }
107
108 let default_region = profile
109 .default_region
110 .clone()
111 .unwrap_or_else(|| "us-east-1".to_string());
112
113 let client = pool
117 .inner
118 .get_or_build(&profile_id, &default_region)
119 .await
120 .ok_or_else(|| AppError::Internal {
121 trace_id: format!("pool_miss:profile:{}", profile_id.as_str()),
122 })?;
123
124 let token = {
128 let mut reg = search_registry.inner.write().await;
129 reg.register(request_id.clone())
130 };
131
132 let _transfer_concurrency = {
137 let s = settings.inner.lock().await;
138 s.transfer_concurrency
139 };
140
141 let rid = request_id.clone();
145 let bucket_str = bucket.as_str().to_string();
146 let prefix_clone = prefix.clone();
147 let query_clone = query.clone();
148 let registry_handle = search_registry.inner.clone();
149
150 tokio::spawn(async move {
151 let mut continuation_token: Option<String> = None;
152 let mut page_index: u32 = 0;
153 let q = query_clone.to_lowercase();
154
155 loop {
156 if token.is_cancelled() {
158 let final_page = SearchPage {
160 request_id: rid.clone(),
161 page_index,
162 results: vec![],
163 is_final: true,
164 };
165 let _ = events::emit(&channel, EventKind::SearchPage, &final_page);
166 break;
167 }
168
169 let ct_ref = continuation_token.as_deref();
170
171 let list_result =
172 list_objects_flat(&client, &bucket_str, &prefix_clone, ct_ref, Some(1000)).await;
173
174 let page = match list_result {
175 Ok(p) => p,
176 Err(_) => {
177 let final_page = SearchPage {
179 request_id: rid.clone(),
180 page_index,
181 results: vec![],
182 is_final: true,
183 };
184 let _ = events::emit(&channel, EventKind::SearchPage, &final_page);
185 break;
186 }
187 };
188
189 let is_last_page = !page.is_truncated || page.next_continuation_token.is_none();
190
191 let results: Vec<EntryRef> = page
193 .entries
194 .iter()
195 .filter(|e| q.is_empty() || e.key.to_lowercase().contains(&q))
196 .map(|e| EntryRef {
197 key: e.key.clone(),
198 size: e.size,
199 last_modified: e.last_modified,
200 is_prefix: e.is_prefix,
201 })
202 .collect();
203
204 let search_page = SearchPage {
205 request_id: rid.clone(),
206 page_index,
207 results,
208 is_final: is_last_page,
209 };
210
211 let _ = events::emit(&channel, EventKind::SearchPage, &search_page);
212
213 if is_last_page {
214 break;
215 }
216
217 continuation_token = page.next_continuation_token;
218 page_index += 1;
219 }
220
221 let mut reg = registry_handle.write().await;
223 reg.remove(&rid);
224 });
225
226 Ok(request_id)
227}
228
229#[tauri::command]
242pub async fn search_cancel(
243 request_id: String,
244 search_registry: State<'_, SearchRegistryHandle>,
245) -> Result<(), AppError> {
246 let mut reg = search_registry.inner.write().await;
247 reg.cancel(&request_id);
248 Ok(())
249}
250
251#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::events::{EventKind, MockChannel};
259 use crate::search::{EntryRef, SearchPage, SearchRegistry};
260
261 #[tokio::test]
266 async fn local_filter_case_insensitive_substring() {
267 let entries = vec![
268 EntryRef {
269 key: "photos/Image.jpg".to_string(),
270 size: 100,
271 last_modified: None,
272 is_prefix: false,
273 },
274 EntryRef {
275 key: "docs/report.pdf".to_string(),
276 size: 200,
277 last_modified: None,
278 is_prefix: false,
279 },
280 EntryRef {
281 key: "photos/thumbnail.png".to_string(),
282 size: 50,
283 last_modified: None,
284 is_prefix: false,
285 },
286 ];
287 let result = search_local_filter("pane-1".to_string(), "IMAGE".to_string(), entries)
288 .await
289 .unwrap();
290
291 assert_eq!(result.len(), 1);
292 assert_eq!(result[0].key, "photos/Image.jpg");
293 }
294
295 #[tokio::test]
296 async fn local_filter_empty_query_returns_all() {
297 let entries = vec![
298 EntryRef {
299 key: "a.txt".to_string(),
300 size: 1,
301 last_modified: None,
302 is_prefix: false,
303 },
304 EntryRef {
305 key: "b.txt".to_string(),
306 size: 2,
307 last_modified: None,
308 is_prefix: false,
309 },
310 ];
311 let count = entries.len();
312 let result = search_local_filter("pane-1".to_string(), String::new(), entries)
313 .await
314 .unwrap();
315
316 assert_eq!(result.len(), count);
317 }
318
319 #[tokio::test]
320 async fn local_filter_no_match_returns_empty() {
321 let entries = vec![EntryRef {
322 key: "folder/file.txt".to_string(),
323 size: 10,
324 last_modified: None,
325 is_prefix: false,
326 }];
327 let result = search_local_filter("pane-1".to_string(), "zzz-no-match".to_string(), entries)
328 .await
329 .unwrap();
330
331 assert!(result.is_empty());
332 }
333
334 #[tokio::test]
335 async fn local_filter_preserves_prefix_entries() {
336 let entries = vec![
337 EntryRef {
338 key: "logs/".to_string(),
339 size: 0,
340 last_modified: None,
341 is_prefix: true,
342 },
343 EntryRef {
344 key: "data/file.csv".to_string(),
345 size: 500,
346 last_modified: None,
347 is_prefix: false,
348 },
349 ];
350 let result = search_local_filter("pane-1".to_string(), "logs".to_string(), entries)
351 .await
352 .unwrap();
353
354 assert_eq!(result.len(), 1);
355 assert!(result[0].is_prefix);
356 }
357
358 #[test]
363 fn search_registry_register_and_cancel() {
364 let mut reg = SearchRegistry::default();
365 let token = reg.register("req-42");
366 assert!(!token.is_cancelled());
367 assert!(reg.cancel("req-42"));
368 assert!(token.is_cancelled());
369 }
370
371 #[test]
374 fn search_page_event_carries_correct_request_id() {
375 let channel = MockChannel::default();
376 let page = SearchPage {
377 request_id: "req-abc".to_string(),
378 page_index: 0,
379 results: vec![EntryRef {
380 key: "folder/doc.txt".to_string(),
381 size: 42,
382 last_modified: None,
383 is_prefix: false,
384 }],
385 is_final: false,
386 };
387
388 events::emit(&channel, EventKind::SearchPage, &page).unwrap();
389
390 let emitted = channel.emitted();
391 assert_eq!(emitted.len(), 1);
392 assert_eq!(emitted[0].0, EventKind::SearchPage);
393 assert_eq!(emitted[0].1["requestId"], "req-abc");
394 assert_eq!(emitted[0].1["pageIndex"], 0);
395 assert_eq!(emitted[0].1["isFinal"], false);
396 assert_eq!(emitted[0].1["results"][0]["key"], "folder/doc.txt");
397 }
398
399 #[test]
400 fn final_page_has_is_final_true() {
401 let channel = MockChannel::default();
402 let page = SearchPage {
403 request_id: "req-fin".to_string(),
404 page_index: 2,
405 results: vec![],
406 is_final: true,
407 };
408
409 events::emit(&channel, EventKind::SearchPage, &page).unwrap();
410
411 let emitted = channel.emitted();
412 assert_eq!(emitted[0].1["isFinal"], true);
413 assert_eq!(emitted[0].1["requestId"], "req-fin");
414 }
415}