Skip to main content

orbok_ui/
state.rs

1//! Headless UI state (view models) and the message vocabulary.
2//!
3//! Everything here is plain data โ€” testable without a display server.
4//! `orbok-app` populates these structs from backend services; views
5//! render them; `update` mutates them. No iced types appear in this
6//! module so state logic stays UI-framework-agnostic.
7
8use crate::i18n::Locale;
9use orbok_models::SearchCapability;
10use orbok_search::SearchMode;
11use crate::notice::UserNotice;
12
13/// Top-level navigation group for the two-level sidebar + tab layout.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum NavGroup {
16    Search,
17    Ai,
18    Settings,
19}
20
21/// Top-level pages (GUI external design ยง3.1 order).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ViewId {
24    Search,
25    Sources,
26    Indexing,
27    Storage,
28    Models,
29    Settings,
30}
31
32impl ViewId {
33    pub const ALL: &'static [ViewId] = &[
34        ViewId::Search,
35        ViewId::Sources,
36        ViewId::Indexing,
37        ViewId::Storage,
38        ViewId::Models,
39        ViewId::Settings,
40    ];
41
42    /// Which top-level navigation group this view belongs to.
43    pub fn group(self) -> NavGroup {
44        match self {
45            ViewId::Search | ViewId::Sources => NavGroup::Search,
46            ViewId::Indexing | ViewId::Storage | ViewId::Models => NavGroup::Ai,
47            ViewId::Settings => NavGroup::Settings,
48        }
49    }
50
51    /// Default view to activate when the user first enters a group.
52    pub fn group_default(group: NavGroup) -> Self {
53        match group {
54            NavGroup::Search => ViewId::Search,
55            NavGroup::Ai => ViewId::Indexing,
56            NavGroup::Settings => ViewId::Settings,
57        }
58    }
59}
60
61
62/// Sidebar index-health summary.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct IndexHealth {
65    pub indexed: u64,
66    pub stale: u64,
67    pub failed: u64,
68    pub queued: u64,
69}
70
71/// One source card for the Sources view.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SourceCard {
74    pub display_name: String,
75    pub display_path: String,
76    pub indexed: u64,
77    pub stale: u64,
78    pub failed: u64,
79    pub active: bool,
80    pub source_id: String,
81}
82
83/// A search result ready for display โ€” pure data, no backend types
84/// (RFC-027 boundary rule).
85#[derive(Debug, Clone, PartialEq)]
86pub struct SearchResultDisplay {
87    pub display_path: String,
88    pub title: Option<String>,
89    pub heading_path: Option<String>,
90    pub snippet: Option<String>,
91    pub keyword_rank: u32,
92    pub badges: Vec<String>,
93}
94
95
96/// One required file and its check result shown in the wizard.
97#[derive(Debug, Clone, PartialEq)]
98pub struct WizardFileCheck {
99    pub relative_path: String,
100    pub found: bool,
101    pub size_mb: Option<f64>,
102}
103
104/// Which stage of the startup wizard the user is on.
105#[derive(Debug, Clone, PartialEq)]
106pub enum WizardState {
107    /// First launch or model never configured.
108    NotConfigured,
109    /// Was configured, but files are gone.
110    FileMissing { previous_dir: String, checks: Vec<WizardFileCheck> },
111    /// User submitted a path; file checks complete.
112    Checked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
113    /// All files verified โ€” ready to proceed.
114    Ready { model_dir: String },
115    /// HuggingFace download in progress.
116    Downloading {
117        dest_dir: String,
118        /// Filename currently being downloaded.
119        current_file: String,
120        bytes: u64,
121        total: Option<u64>,
122        files_done: u32,
123        files_total: u32,
124    },
125}
126
127/// The whole-app view model.
128#[derive(Debug, Clone)]
129pub struct AppState {
130    pub active_view: ViewId,
131    pub locale: Locale,
132    pub query: String,
133    pub last_query: Option<String>,
134    pub search_mode: SearchMode,
135    pub search_results: Vec<SearchResultDisplay>,
136    pub search_running: bool,
137    pub selected_result: Option<usize>,
138    pub storage_rows: Vec<(String, u64, u64)>,
139    pub health: IndexHealth,
140    pub sources: Vec<SourceCard>,
141    pub capability: SearchCapability,
142    pub storage_total_bytes: u64,
143    /// Active startup wizard, or `None` when startup succeeded.
144    pub wizard: Option<WizardState>,
145    /// Text-input path the user is typing in the wizard.
146    pub wizard_path_input: String,
147    /// Text input for the "add source" path field.
148    pub source_path_input: String,
149    /// When false (default), hide technical detail. Mature users can toggle on.
150    pub show_advanced: bool,
151    /// Active user-facing notice (problem or confirmation), or `None`.
152    pub notice: Option<UserNotice>,
153    /// Awaiting user confirmation before running reset catalog.
154    pub confirm_reset: bool,
155}
156
157impl Default for AppState {
158    fn default() -> Self {
159        Self {
160            active_view: ViewId::Search,
161            locale: Locale::default(),
162            query: String::new(),
163            last_query: None,
164            search_mode: SearchMode::Auto,
165            search_results: Vec::new(),
166            search_running: false,
167            selected_result: None,
168            storage_rows: Vec::new(),
169            health: IndexHealth::default(),
170            sources: Vec::new(),
171            capability: SearchCapability::KeywordOnly,
172            storage_total_bytes: 0,
173            wizard: None,
174            wizard_path_input: String::new(),
175            source_path_input: String::new(),
176            show_advanced: false,
177            notice: None,
178            confirm_reset: false,
179        }
180    }
181}
182
183/// UI messages.
184#[derive(Debug, Clone)]
185pub enum Message {
186    Switch(ViewId),
187    SwitchGroup(NavGroup),
188    ToggleAdvanced,
189    ShowNotice(UserNotice),
190    ClearNotice,
191    // Storage cleanup
192    CleanSnippets,
193    CleanSearchCache,
194    AskResetCatalog,
195    ConfirmResetCatalog,
196    CancelResetCatalog,
197    CleanupDone,        // backend notifies completion
198    // Wizard navigation
199    WizardBack,
200    QueryChanged(String),
201    SubmitSearch,
202    SearchResultsReady(Vec<SearchResultDisplay>),
203    SearchError(String),
204    SelectResult(usize),
205    OpenSourceFile(String),
206    SetSearchMode(SearchMode),
207    PersistLocale(Locale),
208    SetLocale(Locale),
209    StorageDataReady(Vec<(String, u64, u64)>),
210    // Startup wizard
211    WizardPathChanged(String),
212    WizardValidate,
213    WizardChecked { model_dir: String, checks: Vec<WizardFileCheck>, all_ok: bool },
214    WizardAccept,
215    WizardSkip,
216    // Source management
217    SourcePathChanged(String),
218    RequestAddSource,
219    SourceAdded(SourceCard),
220    SourceRemoved(String),   // source_id
221    ScanCompleted(IndexHealth),
222    // Download
223    DownloadModel,
224    DownloadStarted { dest_dir: String },
225    DownloadFileProgress {
226        file: String,
227        bytes: u64,
228        total: Option<u64>,
229        files_done: u32,
230        files_total: u32,
231    },
232    DownloadAllComplete { dest_dir: String },
233    DownloadFailed(String),
234    // Startup population
235    HealthUpdated(IndexHealth),
236    SourcesLoaded(Vec<SourceCard>),
237}
238
239impl AppState {
240    pub fn update(&mut self, message: &Message) {
241        match message {
242            Message::Switch(view) => self.active_view = *view,
243            Message::SwitchGroup(group) => self.active_view = ViewId::group_default(*group),
244            Message::ToggleAdvanced => self.show_advanced = !self.show_advanced,
245            Message::AskResetCatalog => self.confirm_reset = true,
246            Message::CancelResetCatalog => self.confirm_reset = false,
247            Message::ConfirmResetCatalog => {
248                self.confirm_reset = false;
249                // Actual reset handled in orbok-app; UI pre-clears state.
250                self.sources.clear();
251                self.health = crate::state::IndexHealth::default();
252                self.search_results.clear();
253                self.storage_rows.clear();
254                self.storage_total_bytes = 0;
255            }
256            Message::CleanSnippets | Message::CleanSearchCache => {
257                // Actual work done in orbok-app; state update arrives via CleanupDone.
258            }
259            Message::CleanupDone => {
260                self.notice = Some(UserNotice::PreviewsCleared);
261            }
262            Message::WizardBack => {
263                // Return to the initial setup step.
264                self.wizard = Some(crate::state::WizardState::NotConfigured);
265                self.wizard_path_input = String::new();
266            }
267            Message::ShowNotice(n) => self.notice = Some(n.clone()),
268            Message::ClearNotice => self.notice = None,
269            Message::QueryChanged(query) => self.query = query.clone(),
270            Message::SubmitSearch => {
271                let trimmed = self.query.trim();
272                if !trimmed.is_empty() {
273                    self.last_query = Some(trimmed.to_string());
274                    self.search_running = true;
275                    self.search_results.clear();
276                    self.selected_result = None;
277                }
278            }
279            Message::SearchResultsReady(results) => {
280                self.search_results = results.clone();
281                self.search_running = false;
282                self.selected_result = None;
283                self.notice = None;
284            }
285            Message::SearchError(_) => {
286                self.search_running = false;
287                self.notice = Some(UserNotice::SearchDidNotFinish);
288            }
289            Message::SelectResult(idx) => self.selected_result = Some(*idx),
290            Message::OpenSourceFile(_) => {} // handled by orbok-app
291            Message::SetSearchMode(mode) => self.search_mode = *mode,
292            Message::PersistLocale(locale) | Message::SetLocale(locale) => self.locale = *locale,
293            Message::StorageDataReady(rows) => self.storage_rows = rows.clone(),
294            Message::WizardPathChanged(p) => self.wizard_path_input = p.clone(),
295            Message::WizardValidate => {} // handled in orbok-app update
296            Message::WizardChecked { model_dir, checks, all_ok } => {
297                self.wizard = Some(if *all_ok {
298                    WizardState::Ready { model_dir: model_dir.clone() }
299                } else {
300                    WizardState::Checked {
301                        model_dir: model_dir.clone(),
302                        checks: checks.clone(),
303                        all_ok: false,
304                    }
305                });
306            }
307            Message::WizardAccept => {
308                // orbok-app writes the model dir to OrbokSettings; ui
309                // transitions to full capability.
310                self.capability = SearchCapability::Hybrid;
311                self.wizard = None;
312                self.wizard_path_input = String::new();
313            }
314            Message::WizardSkip => {
315                self.capability = SearchCapability::KeywordOnly;
316                self.wizard = None;
317                self.wizard_path_input = String::new();
318            }
319            Message::DownloadModel => {
320                // Transition handled in orbok-app main.rs (needs the data_dir).
321                // The UI just switches to a "waiting" state until DownloadStarted arrives.
322            }
323            Message::DownloadStarted { dest_dir } => {
324                self.wizard = Some(WizardState::Downloading {
325                    dest_dir: dest_dir.clone(),
326                    current_file: String::new(),
327                    bytes: 0,
328                    total: None,
329                    files_done: 0,
330                    files_total: 2,
331                });
332            }
333            Message::DownloadFileProgress { file, bytes, total, files_done, files_total } => {
334                if let Some(WizardState::Downloading { current_file, bytes: b, total: t, files_done: fd, files_total: ft, .. }) =
335                    &mut self.wizard
336                {
337                    *current_file = file.clone();
338                    *b = *bytes;
339                    *t = *total;
340                    *fd = *files_done;
341                    *ft = *files_total;
342                }
343            }
344            Message::DownloadAllComplete { dest_dir } => {
345                // Switch directly to wizard-accepted flow.
346                self.wizard = Some(WizardState::Ready { model_dir: dest_dir.clone() });
347            }
348            Message::DownloadFailed(_reason) => {
349                // Return to NotConfigured so the user can try again.
350self.wizard = Some(WizardState::NotConfigured);
351            }
352            Message::SourcePathChanged(p) => self.source_path_input = p.clone(),
353            Message::RequestAddSource => {} // handled in orbok-app
354            Message::SourceAdded(card) => {
355                self.sources.push(card.clone());
356                self.source_path_input = String::new();
357                self.notice = Some(UserNotice::FolderAdded);
358            }
359            Message::SourceRemoved(id) => self.sources.retain(|s| s.source_id != *id),
360            Message::ScanCompleted(health) | Message::HealthUpdated(health) => {
361                self.health = *health;
362                // Update per-source counts from the fresh health data.
363            }
364            Message::SourcesLoaded(cards) => self.sources = cards.clone(),
365        }
366    }
367}