1pub mod wizard;
8pub use wizard::wizard_view;
9
10use crate::i18n::{Locale, MessageKey, files_indexed, search_result_count, source_summary, tr};
11use crate::state::{AppState, Message};
12use snora::lucide;
13use iced::widget::{button, column, container, row, text, text_input};
14use iced::{Element, Length, Padding};
15use orbok_models::SearchCapability;
16
17fn icon_text<'a>(glyph: char, size: f32) -> iced::widget::Text<'a> {
22 iced::widget::text(glyph.to_string())
23 .font(iced::Font::with_name("lucide"))
24 .size(size)
25}
26
27fn icon_btn<'a>(
29 icon_el: iced::widget::Text<'a>,
30 label: &'a str,
31 msg: Message,
32) -> iced::widget::Button<'a, Message> {
33 button(row![icon_el, text(label).size(15)].spacing(6))
34 .padding(Padding::from([12.0, 16.0]))
35 .on_press(msg)
36}
37
38fn friendly_notice<'a>(
43 locale: Locale,
44 notice: &crate::notice::UserNotice,
45) -> Element<'a, Message> {
46 let action_label = notice
47 .action(locale)
48 .unwrap_or_else(|| tr(locale, MessageKey::NoticeDismiss));
49 container(
50 column![
51 text(notice.title(locale).to_string()).size(18),
52 text(notice.body(locale).to_string()).size(15),
53 button(text(action_label.to_string()).size(15))
54 .padding(Padding::from([10.0, 16.0]))
55 .on_press(Message::ClearNotice),
56 ]
57 .spacing(8),
58 )
59 .padding(Padding::from([16.0, 16.0]))
60 .width(Length::Fill)
61 .into()
62}
63
64fn page<'a>(content: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
65 container(
66 iced::widget::scrollable(
67 container(content.spacing(10))
68 .padding(Padding::from([28.0, 40.0]))
69 .width(Length::Fill),
70 )
71 .height(Length::Fill),
72 )
73 .width(Length::Fill)
74 .height(Length::Fill)
75 .into()
76}
77
78fn heading(label: &str) -> iced::widget::Text<'_> {
79 text(label.to_string()).size(26)
80}
81
82pub fn search_view(state: &AppState) -> Element<'_, Message> {
84 let locale = state.locale;
85 let input = text_input(tr(locale, MessageKey::SearchPlaceholder), &state.query)
86 .on_input(Message::QueryChanged)
87 .on_submit(Message::SubmitSearch)
88 .padding(8);
89 let submit = icon_btn(icon_text(char::from(lucide::Search), 13.0), tr(locale, MessageKey::SearchButton), Message::SubmitSearch);
90
91 let mut content = column![
92 heading(tr(locale, MessageKey::NavSearch)),
93 row![container(input).width(Length::Fill), submit].spacing(8),
94 ];
95
96 if let Some(notice) = &state.notice {
99 content = content.push(friendly_notice(locale, notice));
100 }
101
102 if state.show_advanced {
105 content = content.push(
106 row![
107 text(tr(locale, MessageKey::SearchModeLabel)).size(12),
108 button(text(tr(locale, MessageKey::SearchModeAuto)).size(11))
109 .on_press(Message::SetSearchMode(orbok_search::SearchMode::Auto)),
110 button(text(tr(locale, MessageKey::SearchModeExact)).size(11))
111 .on_press(Message::SetSearchMode(orbok_search::SearchMode::Exact)),
112 button(text(tr(locale, MessageKey::SearchModeConceptual)).size(11))
113 .on_press(Message::SetSearchMode(orbok_search::SearchMode::Conceptual)),
114 ]
115 .spacing(4),
116 );
117 }
118
119 if state.sources.is_empty() {
120 content = content.push(
122 column![
123 text(tr(locale, MessageKey::SearchNoSourcesTitle)).size(18),
124 text(tr(locale, MessageKey::SearchNoSourcesBody)).size(15),
125 button(text(tr(locale, MessageKey::SearchAddSource)).size(15))
126 .on_press(Message::Switch(crate::state::ViewId::Sources)),
127 ]
128 .spacing(6),
129 );
130 } else {
131 if state.capability == SearchCapability::KeywordOnly {
132 content = content
133 .push(text(tr(locale, MessageKey::SearchKeywordOnlyNotice)).size(12));
134 }
135 if state.search_running {
136 content = content.push(text("Searching…").size(15));
137 } else if let Some(last) = &state.last_query {
138 if state.search_results.is_empty() {
139 content = content.push(
140 column![
141 text(tr(locale, MessageKey::SearchNoResults)).size(15),
142 text(format!("Query: {last}")).size(12),
143 ]
144 .spacing(4),
145 );
146 } else {
147 content = content.push(
148 text(search_result_count(locale, state.search_results.len())).size(12),
149 );
150 for (i, result) in state.search_results.iter().enumerate() {
151 let is_selected = state.selected_result == Some(i);
152 let title_raw = result.title.as_deref().unwrap_or(&result.display_path);
153 let title_str = if is_selected {
154 std::borrow::Cow::Owned(format!("▶ {title_raw}"))
155 } else {
156 std::borrow::Cow::Borrowed(title_raw)
157 };
158 let title_str: &str = &title_str;
159 let snippet_str = result
160 .snippet
161 .as_deref()
162 .unwrap_or("(source unavailable)");
163 let heading_str = result.heading_path.as_deref().unwrap_or("");
164 let card = container(
165 column![
166 text(title_str.to_string()).size(15),
167 text(result.display_path.clone()).size(12),
168 if !heading_str.is_empty() { text(heading_str.to_string()).size(11) }
169 else { text("").size(11) },
170 text(snippet_str.chars().take(120).collect::<String>()).size(12),
171 {
172 let shown: Vec<String> = if state.show_advanced {
176 result.badges.clone()
177 } else {
178 result.badges.iter()
179 .filter(|b| {
180 let l = b.to_lowercase();
181 l.contains("stale") || l.contains("missing")
182 })
183 .cloned()
184 .collect()
185 };
186 text(shown.join(" ")).size(11)
187 },
188 ]
189 .spacing(2),
190 )
191 .padding(10);
192 content = content.push(
193 button(card).on_press(Message::SelectResult(i)),
194 );
195 }
196 }
197 }
198 }
199 page(content)
200}
201
202pub fn sources_view(state: &AppState) -> Element<'_, Message> {
204 let locale = state.locale;
205 let add_btn = icon_btn(icon_text(char::from(lucide::FolderPlus), 13.0), tr(locale, MessageKey::SourcesAddFolder), Message::RequestAddSource);
207 let add_input = text_input(
208 "Or type a path manually…",
209 &state.source_path_input,
210 )
211 .on_input(Message::SourcePathChanged)
212 .on_submit(Message::RequestAddSource)
213 .padding(8);
214 let recursive_note = text("All sub-folders are scanned recursively.").size(12);
215 let mut content = column![
216 heading(tr(locale, MessageKey::SourcesTitle)),
217 row![add_btn, container(add_input).width(Length::Fill)].spacing(8),
218 recursive_note,
219 ];
220
221 if let Some(notice) = &state.notice {
222 content = content.push(friendly_notice(locale, notice));
223 }
224 if state.sources.is_empty() {
225 content = content.push(
226 column![
227 text(tr(locale, MessageKey::SourcesEmptyTitle)).size(18),
228 text(tr(locale, MessageKey::SourcesEmptyBody)).size(15),
229 ]
230 .spacing(6),
231 );
232 } else {
233 for card in &state.sources {
234 let status = if card.active {
235 tr(locale, MessageKey::SourcesStatusActive)
236 } else {
237 tr(locale, MessageKey::SourcesStatusPaused)
238 };
239 let src_id = card.source_id.clone();
240 content = content.push(
241 container(
242 column![
243 text(card.display_name.clone()).size(15),
244 text(card.display_path.clone()).size(12),
245 text(source_summary(locale, card.indexed, card.stale, card.failed))
246 .size(12),
247 row![
248 text(status).size(11),
249 button(row![icon_text(char::from(lucide::Trash2), 12.0).size(12)].spacing(2))
250 .on_press(Message::SourceRemoved(src_id)),
251 ].spacing(8),
252 ]
253 .spacing(2),
254 )
255 .padding(10),
256 );
257 }
258 }
259 page(content)
260}
261
262pub fn indexing_view(state: &AppState) -> Element<'_, Message> {
264 let locale = state.locale;
265 let h = state.health;
266 let cell = |label: &'static str, value: u64| {
267 container(column![text(label).size(12), text(value.to_string()).size(20)].spacing(2))
268 .padding(10)
269 };
270 let mut cells = row![cell(tr(locale, MessageKey::IndexingHealthIndexed), h.indexed)]
274 .spacing(10);
275 if h.queued > 0 || state.show_advanced {
276 cells = cells.push(cell(tr(locale, MessageKey::IndexingHealthQueued), h.queued));
277 }
278 if h.stale > 0 || state.show_advanced {
279 cells = cells.push(cell(tr(locale, MessageKey::IndexingHealthStale), h.stale));
280 }
281 if h.failed > 0 || state.show_advanced {
282 cells = cells.push(cell(tr(locale, MessageKey::IndexingHealthFailed), h.failed));
283 }
284 let content = column![
285 heading(tr(locale, MessageKey::IndexingTitle)),
286 cells,
287 text(if h.queued == 0 {
288 tr(locale, MessageKey::IndexingIdle).to_string()
289 } else {
290 files_indexed(locale, h.indexed)
291 })
292 .size(13),
293 ];
294 page(content)
295}
296
297pub fn storage_view(state: &AppState) -> Element<'_, Message> {
299 let locale = state.locale;
300
301 if state.confirm_reset {
303 let content = column![
304 text(tr(locale, MessageKey::StorageResetCatalog)).size(22),
305 text(tr(locale, MessageKey::StorageResetWarning)).size(15),
306 row![
307 button(text(tr(locale, MessageKey::Cancel)).size(15))
308 .padding(Padding::from([12.0, 18.0]))
309 .on_press(Message::CancelResetCatalog),
310 button(text(tr(locale, MessageKey::StorageResetCatalog)).size(15))
311 .padding(Padding::from([12.0, 18.0]))
312 .on_press(Message::ConfirmResetCatalog),
313 ]
314 .spacing(12),
315 ]
316 .spacing(16);
317 return page(content);
318 }
319 let total_bytes: u64 = state.storage_rows.iter().map(|(_, b, _)| b).sum();
320 let gib = total_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
321
322 let mut breakdown = column![
323 text(tr(locale, MessageKey::StorageTitle)).size(26),
324 text(tr(locale, MessageKey::StorageIntro)).size(15),
325 text(format!("{gib:.3} GiB total")).size(20),
326 ]
327 .spacing(4);
328
329 if !state.storage_rows.is_empty() {
330 if state.show_advanced {
331 for (category, bytes, count) in &state.storage_rows {
333 if *bytes > 0 || *count > 0 {
334 let mib = *bytes as f64 / (1024.0 * 1024.0);
335 breakdown = breakdown.push(
336 text(format!(" {category}: {mib:.1} MiB ({count} items)")).size(12),
337 );
338 }
339 }
340 } else {
341 let mut search_index = 0u64;
343 let mut ai_models = 0u64;
344 let mut caches = 0u64;
345 for (category, bytes, _) in &state.storage_rows {
346 match category.as_str() {
347 "keyword_index" | "vector_index" => search_index += bytes,
348 "model_files" => ai_models += bytes,
349 "snippet_cache" | "search_cache" | "temporary_extraction" => caches += bytes,
350 _ => {} }
352 }
353 let mib = |b: u64| b as f64 / (1024.0 * 1024.0);
354 for (label, bytes) in [
355 (tr(locale, MessageKey::StorageGroupSearchIndex), search_index),
356 (tr(locale, MessageKey::StorageGroupModels), ai_models),
357 (tr(locale, MessageKey::StorageGroupCaches), caches),
358 ] {
359 if bytes > 0 {
360 breakdown = breakdown.push(
361 text(format!(" {label}: {:.1} MiB", mib(bytes))).size(15),
362 );
363 }
364 }
365 }
366 }
367
368 let content = column![
369 breakdown,
370 text(tr(locale, MessageKey::StorageSafeCleanupHeading)).size(15),
371 row![
372 button(text(tr(locale, MessageKey::StorageClearSnippets)).size(15))
373 .padding(Padding::from([12.0, 16.0]))
374 .on_press(Message::CleanSnippets),
375 button(text(tr(locale, MessageKey::StorageClearSearchCache)).size(15))
376 .padding(Padding::from([12.0, 16.0]))
377 .on_press(Message::CleanSearchCache),
378 ]
379 .spacing(8),
380 text(tr(locale, MessageKey::StorageDangerHeading)).size(15),
381 button(text(tr(locale, MessageKey::StorageResetCatalog)).size(13))
382 .padding(Padding::from([12.0, 16.0]))
383 .on_press(Message::AskResetCatalog),
384 text(tr(locale, MessageKey::StorageResetWarning)).size(11),
385 ];
386 page(content)
387}
388
389pub fn models_view(state: &AppState) -> Element<'_, Message> {
391 let locale = state.locale;
392 let available = tr(locale, MessageKey::ModelsStatusAvailable);
393 let missing = tr(locale, MessageKey::ModelsStatusMissing);
394 let (embedding, reranker) = match state.capability {
395 SearchCapability::KeywordOnly => (missing, missing),
396 SearchCapability::Hybrid => (available, missing),
397 SearchCapability::HybridWithRerank => (available, available),
398 };
399 let mut content = column![
400 heading(tr(locale, MessageKey::ModelsTitle)),
401 text(format!("{}: {embedding}", tr(locale, MessageKey::ModelsEmbeddingRole))).size(14),
402 text(format!("{}: {reranker}", tr(locale, MessageKey::ModelsRerankerRole))).size(14),
403 ];
404 if state.capability == SearchCapability::KeywordOnly {
405 content = content.push(text(tr(locale, MessageKey::ModelsKeywordOnlyHint)).size(12));
406 }
407 page(content)
408}
409
410pub fn settings_view(state: &AppState) -> Element<'_, Message> {
412 let locale = state.locale;
413 let mut language_row = row![].spacing(8);
414 for candidate in Locale::ALL {
415 let label = text(candidate.display_name()).size(13);
416 let mut b = button(label);
417 if *candidate != locale {
418 b = b.on_press(Message::SetLocale(*candidate));
419 }
420 language_row = language_row.push(b);
421 }
422 let content = column![
423 heading(tr(locale, MessageKey::SettingsTitle)),
424 text(tr(locale, MessageKey::SettingsLanguageHeading)).size(15),
425 language_row,
426 text(tr(locale, MessageKey::SettingsPrivacyHeading)).size(15),
427 text(tr(locale, MessageKey::SettingsPrivacyLocalOnly)).size(13),
428 text(tr(locale, MessageKey::SettingsAdvancedHeading)).size(15),
429 row![
430 button(
431 text(if state.show_advanced {
432 tr(locale, MessageKey::SettingsAdvancedOn)
433 } else {
434 tr(locale, MessageKey::SettingsAdvancedOff)
435 })
436 .size(13),
437 )
438 .on_press(Message::ToggleAdvanced),
439 text(tr(locale, MessageKey::SettingsAdvancedHint)).size(11),
440 ]
441 .spacing(8),
442 ];
443 page(content)
444}