Skip to main content

ublx/layout/
setup.rs

1//! 3-panel TUI: categories (left), contents (middle), preview (right).
2//!
3//! [`crate::handlers::core::run_tui_session`] drives the loop; work per tick is split into four phases (see classification below).
4//! Action application (key → state changes) lives in [`crate::handlers::state_transitions`].
5
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, mpsc};
10use std::time::Instant;
11
12use ratatui::style::Style;
13use ratatui::widgets::ListState;
14
15use crate::engine::{
16    cache,
17    db_ops::{DeltaType, UblxDbCategory},
18    viewer_async::ViewerAsyncState,
19};
20use crate::integrations::{ZahirFT, file_type_from_metadata_name};
21use crate::render::viewers::pdf_preview::PDFPrefetch;
22use crate::utils::{ClipboardCopyCommand, ToastSlot};
23
24use super::style;
25
26/// Re-export snapshot row type for layout/view/render (`path`, category, size).
27pub use crate::engine::db_ops::SnapshotTuiRow as TuiRow;
28
29/// Category string for directories in the snapshot (matches [`crate::engine::db_ops::UblxDbCategory`]).
30pub const CATEGORY_DIRECTORY: &str = "Directory";
31
32/// State for horizontal marquee when a list row label overflows (e.g. Duplicates/Lenses left pane).
33#[derive(Debug, Default)]
34pub struct ContentMarqueeState {
35    pub offset: usize,
36    pub last_advance: Option<Instant>,
37    pub anchor: Option<(usize, String)>,
38}
39
40impl ContentMarqueeState {
41    pub fn reset(&mut self) {
42        self.offset = 0;
43        self.last_advance = None;
44        self.anchor = None;
45    }
46}
47
48/// List panels: categories, contents, focus, preview scroll, and highlight style.
49#[derive(Default)]
50pub struct PanelState {
51    pub category_state: ListState,
52    pub content_state: ListState,
53    pub focus: PanelFocus,
54    pub preview_scroll: u16,
55    pub prev_preview_key: Option<(usize, Option<usize>)>,
56    pub highlight_style: Style,
57    pub content_sort: ContentSort,
58    /// Temporary anchor used to keep the same selected item identity after sort changes.
59    pub sort_anchor_path: Option<String>,
60    /// Last converged right-pane body text width (for find footer + tab match counts).
61    pub right_pane_text_w: Option<u16>,
62    /// Marquee for the left category list in Duplicates / Lenses when the selected name overflows.
63    pub category_marquee: ContentMarqueeState,
64    /// Marquee for the middle contents path list when the selected row overflows (Snapshot / Delta / Duplicates / Lenses).
65    pub content_marquee: ContentMarqueeState,
66    /// Zahir compact `columns` table verbosity in Metadata / Writing tabs.
67    pub typed_column_tables: crate::config::ColumnStatsDisplay,
68}
69
70impl PanelState {
71    fn new() -> Self {
72        let mut p = Self {
73            highlight_style: style::list_highlight(),
74            ..Default::default()
75        };
76        p.category_state.select(Some(0));
77        p.content_state.select(Some(0));
78        p
79    }
80}
81
82/// Middle-pane sort direction.
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub enum SortDirection {
85    #[default]
86    Asc,
87    Desc,
88}
89
90impl SortDirection {
91    #[must_use]
92    pub fn next(self) -> Self {
93        match self {
94            Self::Asc => Self::Desc,
95            Self::Desc => Self::Asc,
96        }
97    }
98}
99
100/// Snapshot/Duplicates middle-pane sort key.
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub enum SnapshotSortKey {
103    #[default]
104    Name,
105    Size,
106    Mod,
107}
108
109impl SnapshotSortKey {
110    #[must_use]
111    pub fn next(self) -> Self {
112        match self {
113            Self::Name => Self::Size,
114            Self::Size => Self::Mod,
115            Self::Mod => Self::Name,
116        }
117    }
118}
119
120/// Mode-aware content sort state.
121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
122pub struct ContentSort {
123    pub snapshot_key: SnapshotSortKey,
124    pub snapshot_dir: SortDirection,
125    pub delta_dir: SortDirection,
126}
127
128impl ContentSort {
129    #[must_use]
130    pub fn cycle_for_mode(self, main_mode: MainMode) -> Self {
131        match main_mode {
132            MainMode::Snapshot | MainMode::Duplicates => {
133                if self.snapshot_dir == SortDirection::Asc {
134                    Self {
135                        snapshot_dir: SortDirection::Desc,
136                        ..self
137                    }
138                } else {
139                    Self {
140                        snapshot_key: self.snapshot_key.next(),
141                        snapshot_dir: SortDirection::Asc,
142                        ..self
143                    }
144                }
145            }
146            MainMode::Delta => Self {
147                delta_dir: self.delta_dir.next(),
148                ..self
149            },
150            MainMode::Lenses | MainMode::Settings => self,
151        }
152    }
153}
154
155/// Search bar state.
156#[derive(Default)]
157pub struct SearchState {
158    pub query: String,
159    pub active: bool,
160}
161
162/// In-pane literal search (Shift+S): query, match byte ranges in haystack, current match index.
163#[derive(Default)]
164pub struct ViewerFindState {
165    pub query: String,
166    /// Typing into the find bar (chars go to query).
167    pub active: bool,
168    /// Enter pressed: bar closed, `n` / `N` cycle matches.
169    pub committed: bool,
170    pub ranges: Vec<(usize, usize)>,
171    pub current: usize,
172    /// Fingerprint of `(query, haystack)` last used to build `ranges`.
173    pub last_sync_token: Option<u64>,
174    /// After `n` / `N`, scroll even when the haystack token is unchanged.
175    pub pending_scroll: bool,
176}
177
178/// Theme selector and override.
179#[derive(Default)]
180pub struct ThemeState {
181    pub selector_visible: bool,
182    pub selector_index: usize,
183    pub before_selector: Option<String>,
184    pub override_name: Option<String>,
185}
186
187/// Toast notifications stack and per-operation consumed counts.
188#[derive(Default)]
189pub struct ToastState {
190    pub slots: Vec<ToastSlot>,
191    pub consumed_per_operation: HashMap<String, usize>,
192}
193
194/// Open (Terminal/GUI) menu state.
195#[derive(Default)]
196pub struct OpenMenuState {
197    pub visible: bool,
198    pub path: Option<String>,
199    pub can_terminal: bool,
200    pub selected_index: usize,
201}
202
203/// Lens menu (Add to lens) state.
204#[derive(Default)]
205pub struct LensMenuState {
206    pub visible: bool,
207    /// Relative paths to add (one from the quick actions menu (spacebar), many from multi-select bulk).
208    pub paths: Vec<String>,
209    /// Omit from the picker (Lenses tab: **Add to other lens** must not list the active lens).
210    pub exclude_lens_name: Option<String>,
211    pub selected_index: usize,
212    pub name_input: Option<String>,
213}
214
215/// Quick Actions context menu state.
216#[derive(Default)]
217pub struct QAMenuState {
218    pub visible: bool,
219    pub selected_index: usize,
220    pub kind: Option<SpaceMenuKind>,
221}
222
223/// After Space → Enhance policy: choose auto / manual batch Zahir for this directory subtree (local TOML).
224#[derive(Default)]
225pub struct EnhancePolicyMenuState {
226    pub visible: bool,
227    pub path: Option<String>,
228    pub selected_index: usize,
229}
230
231/// Lens rename input and delete-lens confirmation.
232#[derive(Default)]
233pub struct LensConfirmState {
234    pub rename_input: Option<(String, String)>,
235    pub delete_visible: bool,
236    pub delete_lens_name: Option<String>,
237    pub delete_selected: usize,
238}
239
240/// Confirm delete for a snapshot file or folder (Yes / No).
241#[derive(Default)]
242pub struct FileDeleteConfirmState {
243    pub visible: bool,
244    pub rel_path: Option<String>,
245    /// When set, confirm bulk delete (`rel_path` is ignored).
246    pub bulk_paths: Option<Vec<String>>,
247    pub selected_index: usize,
248}
249
250/// Multi-select in the middle pane (Ctrl+Space on contents; cleared when focus leaves the contents list).
251#[derive(Debug, Default)]
252pub struct MultiselectState {
253    pub active: bool,
254    pub selected: HashSet<String>,
255    pub bulk_menu_visible: bool,
256    pub bulk_menu_selected: usize,
257    /// When true, bulk menu has a fourth row: Enhance with `ZahirScan` (z). Set when opening the menu.
258    pub bulk_menu_zahir_row: bool,
259}
260
261impl MultiselectState {
262    pub fn clear(&mut self) {
263        self.active = false;
264        self.selected.clear();
265        self.bulk_menu_visible = false;
266        self.bulk_menu_selected = 0;
267        self.bulk_menu_zahir_row = false;
268    }
269}
270
271/// After **Ctrl+A**, wait for a letter or show the Command Mode menu (see [`crate::ui::ctrl_chord`]).
272#[derive(Clone, Debug, Default)]
273pub struct CtrlChordState {
274    pub pending: bool,
275    pub menu_visible: bool,
276    pub started: Option<std::time::Instant>,
277}
278
279impl CtrlChordState {
280    #[must_use]
281    pub fn is_active(&self) -> bool {
282        self.pending || self.menu_visible
283    }
284}
285
286/// Command Mode + `p`: pick another indexed root (re-exec `ublx` on that directory).
287#[derive(Default)]
288pub struct UblxSwitchPickerState {
289    pub visible: bool,
290    pub selected_index: usize,
291    pub roots: Vec<PathBuf>,
292}
293
294/// Help overlay and fullscreen right-pane preview.
295#[derive(Default)]
296pub struct ViewerChrome {
297    pub help_visible: bool,
298    /// Section tab index inside the help overlay (`Tab` / Shift+Tab); reset when opening help.
299    pub help_tab: u8,
300    pub viewer_fullscreen: bool,
301    pub ctrl_chord: CtrlChordState,
302    pub ublx_switch: UblxSwitchPickerState,
303}
304
305/// First-run flow when the per-root DB was new: pick root, optional prior roots, then prior-settings or enhance-all.
306#[derive(Debug, Clone)]
307pub struct StartupPromptState {
308    pub phase: StartupPromptPhase,
309}
310
311#[derive(Debug, Clone)]
312pub enum StartupPromptPhase {
313    /// Welcome + root picker: current dir first, then optional recent roots. See [`crate::render::overlays::popup::render_startup_welcome_root_choice`].
314    RootChoice {
315        selected_index: usize,
316        roots: Vec<PathBuf>,
317    },
318    /// Prior settings for this folder: local `ublx.toml` / cache vs start clean. See [`crate::render::overlays::popup::render_startup_previous_settings_prompt`].
319    /// 0 = use saved (copy cache → local when there is no local file), 1 = start fresh.
320    PreviousSettings { selected_index: usize },
321    /// Enable full-directory `ZahirScan` (`enable_enhance_all`). See [`crate::render::overlays::popup::render_startup_enhance_all_prompt`]. 0 = Yes, 1 = No.
322    Enhance { selected_index: usize },
323}
324
325/// Background snapshot: user request, poll `.ublx_tmp` while running, and completion.
326#[derive(Default)]
327pub struct BackgroundSnapshot {
328    pub requested: bool,
329    pub poll_deadline: Option<std::time::Instant>,
330    pub done_received: bool,
331    /// After the in-flight snapshot finishes, run one more (e.g. `[[enhance_policy]]` = auto just saved).
332    pub defer_snapshot_after_current: bool,
333}
334
335/// Lazy-load duplicate groups when the user opens the Duplicates tab.
336#[derive(Default)]
337pub struct DuplicateLoadGate {
338    pub requested: bool,
339}
340
341/// Background flat Zahir JSON export (Command Mode + `x`).
342#[derive(Default)]
343pub struct ZahirExportGate {
344    pub requested: bool,
345}
346
347/// Background lens Markdown export (Command Mode + `l`).
348#[derive(Default)]
349pub struct LensExportGate {
350    pub requested: bool,
351}
352
353/// First real frame vs later ticks; redraw after returning from external editor.
354#[derive(Clone, Copy, Debug)]
355pub struct SessionTickFlags {
356    pub first_tick: bool,
357    pub refresh_terminal_after_editor: bool,
358}
359
360impl Default for SessionTickFlags {
361    fn default() -> Self {
362        Self {
363            first_tick: true,
364            refresh_terminal_after_editor: false,
365        }
366    }
367}
368
369/// Snapshot table reload and one-shot dedup for the background full-enhance toast.
370#[derive(Clone, Copy, Debug, Default)]
371pub struct SessionReloadFlags {
372    /// After single-file `ZahirScan` enhance, reload snapshot rows from DB on next tick.
373    pub snapshot_rows: bool,
374    /// After we show the "enhancing in background" toast for [`crate::engine::orchestrator::should_force_full_zahir`], suppress duplicates until restart.
375    pub force_full_enhance_toast_shown: bool,
376    /// After deleting a file from Duplicates mode, reload duplicate groups from the DB on next tick.
377    pub duplicate_groups: bool,
378}
379
380/// One-shot session coordination for ticks, editor handoff, and DB reload.
381#[derive(Default)]
382pub struct SessionFlow {
383    pub tick: SessionTickFlags,
384    pub reload: SessionReloadFlags,
385    /// Set when the user confirms another indexed root in the project picker; next tick runs [`crate::handlers::session_switch::perform_session_switch`].
386    pub pending_switch_to: Option<PathBuf>,
387}
388
389pub struct PDF {
390    pub page: u32,
391    pub page_count: Option<u32>,
392    pub for_path: Option<PathBuf>,
393    pub page_count_rx: Option<mpsc::Receiver<Result<u32, String>>>,
394    pub prefetch_cancel: Arc<AtomicU64>,
395    pub prefetch_earliest: Option<Instant>,
396    pub prefetch_rx: Option<mpsc::Receiver<(String, Result<image::DynamicImage, String>)>>,
397}
398
399impl Default for PDF {
400    fn default() -> Self {
401        Self {
402            page: 1,
403            page_count: None,
404            for_path: None,
405            page_count_rx: None,
406            prefetch_cancel: Arc::new(AtomicU64::new(0)),
407            prefetch_earliest: None,
408            prefetch_rx: None,
409        }
410    }
411}
412
413/// State for the image viewer in the right pane (`ratatui-image`, tiered downscale, optional background decode).
414#[derive(Default)]
415pub struct ViewerImageState {
416    pub protocol: Option<ratatui_image::protocol::StatefulProtocol>,
417    pub picker: Option<ratatui_image::picker::Picker>,
418    /// Cache key: path display, or `path#pN` for PDF page `N`.
419    pub key: Option<String>,
420    /// When set, a background thread is decoding/downsizing; poll in [`crate::render::viewers::image::ensure_viewer_image`].
421    pub decode_rx: Option<mpsc::Receiver<Result<image::DynamicImage, String>>>,
422    pub err: Option<String>,
423    /// Recent previews (not the current row). Size [`Self::LRU_CAP`] is tied to PDF prefetch (see [`ViewerImageState::LRU_CAP`]).
424    pub image_lru: VecDeque<(String, ratatui_image::protocol::StatefulProtocol)>,
425    /// PDF: one-based page; PDF: selected file this state applies to.
426    pub pdf: PDF,
427}
428
429impl ViewerImageState {
430    /// `PDFPrefetch::MAX_EXTRA_PAGES` prefetched PDFs (pages 2..) plus **four** slots to stash the previous page
431    pub const LRU_EXTRA_SLOTS: usize = 4;
432    pub const LRU_CAP: usize = PDFPrefetch::MAX_EXTRA_PAGES as usize + Self::LRU_EXTRA_SLOTS;
433
434    /// Push a finished preview into the LRU ring; drops the oldest entry when full.
435    pub fn push_lru(&mut self, path: String, proto: ratatui_image::protocol::StatefulProtocol) {
436        while self.image_lru.len() >= Self::LRU_CAP {
437            self.image_lru.pop_front();
438        }
439        self.image_lru.push_back((path, proto));
440    }
441
442    /// Remove and return a cached protocol for `path` if present.
443    pub fn take_from_lru(
444        &mut self,
445        path: &str,
446    ) -> Option<ratatui_image::protocol::StatefulProtocol> {
447        let pos = self.image_lru.iter().position(|(k, _)| k == path)?;
448        self.image_lru.remove(pos).map(|(_, proto)| proto)
449    }
450
451    /// Drop an LRU entry matching `key` so a prefetch can replace it.
452    pub fn remove_lru_key(&mut self, key: &str) {
453        if let Some(pos) = self.image_lru.iter().position(|(k, _)| k == key) {
454            self.image_lru.remove(pos);
455        }
456    }
457
458    /// Clear loaded image, error, and async decode channel; **retains** [`Self::picker`] so the
459    /// terminal is not re-queried on every selection (matches previous flat-field behavior).
460    /// Finished previews are moved into [`Self::image_lru`] so returning to an image can be instant.
461    pub fn clear(&mut self) {
462        self.pdf.prefetch_cancel.fetch_add(1, Ordering::SeqCst);
463        self.pdf.prefetch_rx = None;
464        self.pdf.prefetch_earliest = None;
465        self.decode_rx = None;
466        self.pdf.page_count_rx = None;
467        self.err = None;
468        let k = self.key.take();
469        let p = self.protocol.take();
470        if let (Some(k), Some(p)) = (k, p) {
471            self.push_lru(k, p);
472        }
473        self.pdf.for_path = None;
474        self.pdf.page = 1;
475        self.pdf.page_count = None;
476    }
477}
478
479/// Avoids re-reading the selected file every UI tick when path, category, size, and mtime match.
480#[derive(Debug, Clone)]
481pub struct ViewerDiskContentCache {
482    pub rel_path: String,
483    /// Snapshot category (drives file-type handling in the viewer).
484    pub category: String,
485    pub file_len: u64,
486    pub modified: Option<std::time::SystemTime>,
487    pub viewer_str: Option<String>,
488    pub embedded_cover_raster: Option<Vec<u8>>,
489    pub viewer_can_open: bool,
490}
491
492impl ViewerDiskContentCache {
493    #[must_use]
494    pub fn matches(&self, path: &str, category: &str, meta: &std::fs::Metadata) -> bool {
495        self.rel_path == path
496            && self.category == category
497            && self.file_len == meta.len()
498            && self.modified == meta.modified().ok()
499    }
500}
501
502#[derive(Default)]
503pub struct RightPaneAsync {
504    pub generation: u64,
505    pub last_spawn_path: String,
506    pub displayed: RightPaneContent,
507    pub rx: Option<tokio::sync::mpsc::UnboundedReceiver<RightPaneAsyncReady>>,
508}
509
510/// Top-level TUI state. Menu and UI sub-states are grouped into nested structs.
511pub struct UblxState {
512    pub main_mode: MainMode,
513    pub right_pane_mode: RightPaneMode,
514    pub panels: PanelState,
515    pub search: SearchState,
516    pub viewer_find: ViewerFindState,
517    pub theme: ThemeState,
518    pub toasts: ToastState,
519    pub open_menu: OpenMenuState,
520    pub lens_menu: LensMenuState,
521    pub qa_menu: QAMenuState,
522    pub enhance_policy_menu: EnhancePolicyMenuState,
523    pub lens_confirm: LensConfirmState,
524    /// Rename entry: `(relative path, new basename being typed)`.
525    pub file_rename_input: Option<(String, String)>,
526    pub file_delete_confirm: FileDeleteConfirmState,
527    pub multiselect: MultiselectState,
528    pub chrome: ViewerChrome,
529    pub cached_tree: Option<(String, String)>,
530    /// Same file row as last tick: reuse viewer text / cover bytes without disk reads.
531    pub viewer_disk_cache: Option<ViewerDiskContentCache>,
532    /// Viewer: large markdown only — cached styled [`Text`] + viewport slice on scroll.
533    pub viewer_text_cache: Option<cache::ViewerTextCacheEntry>,
534    /// Last rendered preview fingerprint for invalidating viewer caches when path or buffer identity changes.
535    pub viewer_preview_source: Option<(String, cache::ViewerContentIdentity)>,
536    /// Viewer: up to [`crate::engine::cache::VIEWER_TEXT_CACHE`] `csv_lru_cap` delimiter-table `Text` bodies by path/width/theme/revision.
537    pub csv_table_text_lru:
538        cache::LruCache<cache::ViewerTableCacheKey, cache::ViewerTextCacheEntry>,
539    /// Large markdown / syntect / CSV table builds off the UI thread ([`crate::render::viewers::async_render`]).
540    pub viewer_async: ViewerAsyncState,
541    /// Image category viewer ([`RightPaneContent::derived`] `abs_path` + [`crate::render::viewers::image`]).
542    pub viewer_image: ViewerImageState,
543    pub last_key_for_double: Option<char>,
544    pub snapshot_bg: BackgroundSnapshot,
545    pub duplicate_load: DuplicateLoadGate,
546    pub zahir_export_load: ZahirExportGate,
547    pub lens_export_load: LensExportGate,
548    /// Duplicates tab: paths hidden for this session via Space → Ignore (i); not persisted.
549    pub duplicate_ignored_paths: HashSet<String>,
550    pub config_written_by_us_at: Option<std::time::Instant>,
551    pub session: SessionFlow,
552    /// CLI to pipe UTF-8 into for clipboard (see [`ClipboardCopyCommand::detect`]); None if nothing found.
553    pub clipboard_copy: Option<ClipboardCopyCommand>,
554    /// Shown when the per-root DB file under `ubli/` was new this run ([`crate::config::paths::should_show_initial_prompt`]).
555    pub startup_prompt: Option<StartupPromptState>,
556    pub settings: SettingsPaneState,
557    pub right_pane_async: RightPaneAsync,
558}
559
560impl Default for UblxState {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566impl UblxState {
567    #[must_use]
568    pub fn new() -> Self {
569        Self {
570            main_mode: MainMode::default(),
571            right_pane_mode: RightPaneMode::default(),
572            panels: PanelState::new(),
573            search: SearchState::default(),
574            viewer_find: ViewerFindState::default(),
575            theme: ThemeState::default(),
576            toasts: ToastState::default(),
577            open_menu: OpenMenuState::default(),
578            lens_menu: LensMenuState::default(),
579            qa_menu: QAMenuState::default(),
580            enhance_policy_menu: EnhancePolicyMenuState::default(),
581            lens_confirm: LensConfirmState::default(),
582            file_rename_input: None,
583            file_delete_confirm: FileDeleteConfirmState::default(),
584            multiselect: MultiselectState::default(),
585            chrome: ViewerChrome::default(),
586            cached_tree: None,
587            viewer_disk_cache: None,
588            viewer_text_cache: None,
589            viewer_preview_source: None,
590            csv_table_text_lru: cache::LruCache::default(),
591            viewer_async: ViewerAsyncState::default(),
592            viewer_image: ViewerImageState::default(),
593            last_key_for_double: None,
594            snapshot_bg: BackgroundSnapshot::default(),
595            duplicate_load: DuplicateLoadGate::default(),
596            zahir_export_load: ZahirExportGate::default(),
597            lens_export_load: LensExportGate::default(),
598            duplicate_ignored_paths: HashSet::new(),
599            config_written_by_us_at: None,
600            session: SessionFlow::default(),
601            clipboard_copy: ClipboardCopyCommand::detect(),
602            startup_prompt: None,
603            settings: SettingsPaneState::default(),
604            right_pane_async: RightPaneAsync::default(),
605        }
606    }
607
608    /// Reset open menu state (Esc or after action).
609    pub fn close_open_menu(&mut self) {
610        self.open_menu.visible = false;
611        self.open_menu.path = None;
612        self.open_menu.can_terminal = false;
613    }
614
615    /// Open the Open (Terminal/GUI) menu. When `can_open_in_terminal` is true, show both options; otherwise only Open (GUI).
616    pub fn open_open_menu(&mut self, path: String, can_open_in_terminal: bool) {
617        self.open_menu.visible = true;
618        self.open_menu.path = Some(path);
619        self.open_menu.can_terminal = can_open_in_terminal;
620        self.open_menu.selected_index = 0;
621    }
622
623    /// Reset lens menu state (Esc or after adding to lens). Does not clear [`LensMenuState::name_input`].
624    pub fn close_lens_menu(&mut self) {
625        self.lens_menu.visible = false;
626        self.lens_menu.paths.clear();
627        self.lens_menu.selected_index = 0;
628    }
629
630    /// Reset spacebar context menu state.
631    pub fn close_qa_menu(&mut self) {
632        self.qa_menu.visible = false;
633        self.qa_menu.selected_index = 0;
634        self.qa_menu.kind = None;
635    }
636
637    pub fn close_enhance_policy_menu(&mut self) {
638        self.enhance_policy_menu.visible = false;
639        self.enhance_policy_menu.path = None;
640        self.enhance_policy_menu.selected_index = 0;
641    }
642
643    /// Reset delete-lens confirmation popup state.
644    pub fn close_lens_delete_confirm(&mut self) {
645        self.lens_confirm.delete_visible = false;
646        self.lens_confirm.delete_lens_name = None;
647        self.lens_confirm.delete_selected = 0;
648    }
649
650    /// Open the Lens menu (Add to lens) for the given relative path(s).
651    /// `exclude_current_lens`: lens name to omit from the list (e.g. active lens on Lenses tab).
652    pub fn open_lens_menu(&mut self, paths: Vec<String>, exclude_current_lens: Option<String>) {
653        if paths.is_empty() {
654            return;
655        }
656        self.lens_menu.visible = true;
657        self.lens_menu.paths = paths;
658        self.lens_menu.exclude_lens_name = exclude_current_lens;
659        self.lens_menu.selected_index = 0;
660    }
661
662    /// Open the spacebar context menu with the given kind.
663    pub fn open_qa_menu(&mut self, kind: SpaceMenuKind) {
664        self.qa_menu.visible = true;
665        self.qa_menu.selected_index = 0;
666        self.qa_menu.kind = Some(kind);
667    }
668
669    /// Show the delete-lens confirmation for the given lens name.
670    pub fn open_lens_delete_confirm(&mut self, lens_name: String) {
671        self.lens_confirm.delete_visible = true;
672        self.lens_confirm.delete_lens_name = Some(lens_name);
673        self.lens_confirm.delete_selected = 0;
674    }
675
676    /// quick actions menu (spacebar) → Rename: centered text input with current basename.
677    pub fn open_file_rename_input(&mut self, rel_path: String) {
678        let base = std::path::Path::new(&rel_path)
679            .file_name()
680            .and_then(|s| s.to_str())
681            .unwrap_or("")
682            .to_string();
683        self.file_rename_input = Some((rel_path, base));
684    }
685
686    pub fn close_file_delete_confirm(&mut self) {
687        self.file_delete_confirm.visible = false;
688        self.file_delete_confirm.rel_path = None;
689        self.file_delete_confirm.bulk_paths = None;
690        self.file_delete_confirm.selected_index = 0;
691    }
692
693    pub fn open_file_delete_confirm(&mut self, rel_path: String) {
694        self.file_delete_confirm.visible = true;
695        self.file_delete_confirm.rel_path = Some(rel_path);
696        self.file_delete_confirm.bulk_paths = None;
697        self.file_delete_confirm.selected_index = 0;
698    }
699
700    pub fn open_file_delete_confirm_bulk(&mut self, paths: Vec<String>) {
701        self.file_delete_confirm.visible = true;
702        self.file_delete_confirm.rel_path = None;
703        self.file_delete_confirm.bulk_paths = Some(paths);
704        self.file_delete_confirm.selected_index = 0;
705    }
706}
707
708/// Which config file the Settings tab edits (`~/.config/ublx/ublx.toml` vs project `ublx.toml`).
709#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
710pub enum SettingsConfigScope {
711    #[default]
712    Global,
713    Local,
714}
715
716/// Settings tab: bool/layout editor, raw TOML preview scroll, and path to the file being edited.
717#[derive(Clone, Debug)]
718pub struct SettingsPaneState {
719    pub scope: SettingsConfigScope,
720    /// Focus row on the left: bool indices, then layout button, then three layout fields when unlocked.
721    pub left_cursor: usize,
722    pub right_scroll: u16,
723    pub layout_unlocked: bool,
724    pub layout_left_buf: String,
725    pub layout_mid_buf: String,
726    pub layout_right_buf: String,
727    pub opacity_unlocked: bool,
728    pub opacity_buf: String,
729    /// Resolved path for the active scope (refreshed on enter / scope change).
730    pub editing_path: Option<std::path::PathBuf>,
731}
732
733impl Default for SettingsPaneState {
734    fn default() -> Self {
735        Self {
736            scope: SettingsConfigScope::Global,
737            left_cursor: 0,
738            right_scroll: 0,
739            layout_unlocked: false,
740            layout_left_buf: String::new(),
741            layout_mid_buf: String::new(),
742            layout_right_buf: String::new(),
743            opacity_unlocked: false,
744            opacity_buf: String::new(),
745            editing_path: None,
746        }
747    }
748}
749
750/// Top-level mode. Tab bar order: Snapshot, Lenses (optional), Delta, Duplicates (optional), Settings.
751#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
752pub enum MainMode {
753    #[default]
754    Snapshot,
755    Delta,
756    /// Single-pane config editor (global / local `ublx.toml`).
757    Settings,
758    Duplicates,
759    Lenses,
760}
761
762impl MainMode {
763    /// Cycle Snapshot → Lenses (if any) → Delta → Duplicates (if any) → Settings → Snapshot (`MainModeToggle` / `~`).
764    #[must_use]
765    pub fn next(self, has_duplicates: bool, has_lenses: bool) -> MainMode {
766        match self {
767            MainMode::Snapshot => {
768                if has_lenses {
769                    MainMode::Lenses
770                } else {
771                    MainMode::Delta
772                }
773            }
774            MainMode::Lenses => MainMode::Delta,
775            MainMode::Delta => {
776                if has_duplicates {
777                    MainMode::Duplicates
778                } else {
779                    MainMode::Settings
780                }
781            }
782            MainMode::Duplicates => MainMode::Settings,
783            MainMode::Settings => MainMode::Snapshot,
784        }
785    }
786}
787
788/// Which panel has focus (Categories or Contents; Metadata is read-only).
789#[derive(Clone, Copy, Default, PartialEq)]
790pub enum PanelFocus {
791    #[default]
792    Categories,
793    Contents,
794}
795
796/// Which variant of the spacebar context menu is open (determines items and Enter behavior).
797#[derive(Clone, Debug)]
798pub enum SpaceMenuKind {
799    /// File actions for a selected file path (relative): Open, Show in folder, optional enhance,
800    /// Add to lens or delete from current lens (Lenses tab uses d), Copy Path, optional Copy Templates, Rename, Delete.
801    /// `can_open_in_terminal`: when true, Open shows Terminal+GUI; else GUI only.
802    FileActions {
803        path: String,
804        can_open_in_terminal: bool,
805        /// Show subtree batch-enhance policy when the snapshot row is [`CATEGORY_DIRECTORY`].
806        show_enhance_directory_policy: bool,
807        /// Show "Enhance with `ZahirScan`" when [`crate::config::UblxOpts::enable_enhance_all`] is false and row has no `zahir_json`.
808        show_enhance_zahir: bool,
809        /// Show "Copy Zahir JSON" when this row has non-empty `zahir_json` in the snapshot (copies raw JSON to clipboard).
810        show_copy_zahir_json: bool,
811    },
812    /// Lens panel actions: `lens_name` is the selected lens. Options: Rename, Delete.
813    LensPanelActions { lens_name: String },
814    /// Duplicates tab only: hide path from duplicate lists for this session, or delete the file.
815    DuplicateMemberActions { path: String },
816}
817
818/// Per-pane content from zahir JSON. Templates always present; metadata and writing only if keys exist.
819pub struct SectionedPreview {
820    pub templates: String,
821    pub metadata: Option<String>,
822    pub writing: Option<String>,
823}
824
825/// Snapshot mode: indices into the single in-memory list (no copy). Delta mode: small owned vec.
826#[derive(Clone)]
827pub enum ViewContents {
828    /// Indices into the caller's `all_rows` slice (snapshot mode — one copy of list).
829    SnapshotIndices(Vec<usize>),
830    /// Owned rows for delta mode (added/mod/removed paths; typically small).
831    DeltaRows(Vec<TuiRow>),
832}
833
834/// Derived list data for this tick: filtered categories and contents (by index or owned), lengths for navigation.
835/// Scalability: snapshot mode uses [`ViewContents::SnapshotIndices`] so we keep a single copy of the list; no cloned row vec.
836pub struct ViewData {
837    pub filtered_categories: Vec<String>,
838    pub contents: ViewContents,
839    pub category_list_len: usize,
840    pub content_len: usize,
841}
842
843impl ViewData {
844    /// Row at content index `i`. For [`ViewContents::SnapshotIndices`], pass `Some(all_rows)`; for [`ViewContents::DeltaRows`], pass `None`.
845    #[must_use]
846    pub fn row_at<'a>(&'a self, i: usize, all_rows: Option<&'a [TuiRow]>) -> Option<&'a TuiRow> {
847        match &self.contents {
848            ViewContents::SnapshotIndices(indices) => indices
849                .get(i)
850                .and_then(|&pos| all_rows.and_then(|r| r.get(pos))),
851            ViewContents::DeltaRows(rows) => rows.get(i),
852        }
853    }
854
855    /// Iterate over content rows. For [`ViewContents::SnapshotIndices`], pass `Some(all_rows)`; for [`ViewContents::DeltaRows`], pass `None`.
856    #[must_use]
857    pub fn iter_contents<'a>(
858        &'a self,
859        all_rows: Option<&'a [TuiRow]>,
860    ) -> Box<dyn Iterator<Item = &'a TuiRow> + 'a> {
861        match &self.contents {
862            ViewContents::SnapshotIndices(indices) => {
863                let iter = indices
864                    .iter()
865                    .filter_map(move |&pos| all_rows.and_then(|r| r.get(pos)));
866                Box::new(iter)
867            }
868            ViewContents::DeltaRows(rows) => Box::new(rows.iter()),
869        }
870    }
871}
872
873/// Raw delta row: (`created_ns`, path) from `delta_log`. Used to build display lines with dates preserved when filtering.
874pub type DeltaRow = (i64, String);
875
876/// Data for Delta mode: snapshot overview text and raw (`created_ns`, path) rows per delta type.
877pub struct DeltaViewData {
878    pub overview_text: String,
879    pub added_rows: Vec<DeltaRow>,
880    pub mod_rows: Vec<DeltaRow>,
881    pub removed_rows: Vec<DeltaRow>,
882}
883
884impl DeltaViewData {
885    /// Raw rows for the given category index. Uses [`DeltaType::from_index`].
886    #[must_use]
887    pub fn rows_by_index(&self, idx: usize) -> &[DeltaRow] {
888        match DeltaType::from_index(idx) {
889            DeltaType::Added => &self.added_rows,
890            DeltaType::Mod => &self.mod_rows,
891            DeltaType::Removed => &self.removed_rows,
892        }
893    }
894}
895
896/// Result from background right-pane resolve.
897#[derive(Debug)]
898pub struct RightPaneAsyncReady {
899    pub generation: u64,
900    pub path: String,
901    pub content: RightPaneContent,
902    pub disk_cache: Option<ViewerDiskContentCache>,
903}
904
905#[derive(Clone, Debug, Default)]
906pub struct SnapshotEntryMeta {
907    pub path: Option<String>,
908    pub category: Option<String>,
909    pub size: Option<u64>,
910    pub mtime_ns: Option<i64>,
911    pub has_zahir_json: bool,
912}
913
914#[derive(Clone, Debug, Default)]
915pub struct RightPaneContentDerived {
916    pub abs_path: Option<PathBuf>,
917    pub can_open: bool,
918    pub offer_enhance_zahir: bool,
919    pub offer_enhance_directory_policy: bool,
920    pub embedded_cover_raster: Option<Vec<u8>>,
921}
922
923/// Text to show in the right pane for the current selection.
924#[derive(Default, Clone, Debug)]
925pub struct RightPaneContent {
926    pub templates: String,
927    pub metadata: Option<String>,
928    pub writing: Option<String>,
929    /// File/tree preview body; shared by reference for async highlight jobs (cheap `Arc::clone`).
930    pub viewer: Option<Arc<str>>,
931    /// When set with a directory tree [`viewer`] body, shown above the tree (bold + italic in the UI).
932    pub viewer_directory_policy_line: Option<String>,
933    pub snap_meta: SnapshotEntryMeta,
934    pub derived: RightPaneContentDerived,
935}
936
937impl RightPaneContent {
938    /// Empty right-pane content (e.g. Delta mode has no selection-based viewer).
939    #[must_use]
940    pub fn empty() -> Self {
941        Self::default()
942    }
943
944    /// Zahir / viewer routing type from snapshot `category` (see [`crate::integrations::file_type_from_metadata_name`]).
945    #[must_use]
946    pub fn zahir_file_type(&self) -> Option<ZahirFT> {
947        file_type_from_metadata_name(self.snap_meta.category.as_deref().unwrap_or(""))
948    }
949
950    /// Snapshot `category` column as [`UblxDbCategory`] (same classification as the DB / [`UblxDbCategory::get_category_for_path`]).
951    #[must_use]
952    pub fn ublx_db_category(&self) -> UblxDbCategory {
953        UblxDbCategory::from_snapshot_category(self.snap_meta.category.as_deref().unwrap_or(""))
954    }
955}
956
957#[derive(Clone, Copy, Default, PartialEq, Eq)]
958pub enum RightPaneMode {
959    #[default]
960    Viewer,
961    Templates,
962    Metadata,
963    Writing,
964}