1use crate::config::load::StatusDisplayMode;
2use crate::fs::git::GitRepoStatus;
3use crossterm::event::Event;
4use ratatui::style::Style;
5use ratatui::text::Line;
6use std::path::PathBuf;
7use std::time::Instant;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum NodeType {
11 File,
12 Directory,
13 Symlink,
14 Unknown,
15}
16
17#[derive(Debug, Clone)]
18pub struct TreeNode {
19 pub path: PathBuf,
20 pub name: String,
21 pub node_type: NodeType,
22 pub depth: usize,
23 pub expanded: bool,
24 pub readable: bool,
25 pub children_loaded: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum LoadState {
30 Idle,
31 Loading,
32 Ready,
33 Error,
34 Binary,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ContentType {
39 Highlighted,
40 PlainText,
41 Unsupported,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum PreviewFallbackReason {
46 UnsupportedExtension,
47 EngineFailure,
48 TooLarge,
49 DecodeUncertain,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct ContentPosition {
54 pub row: usize,
55 pub col: usize,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PreviewSelection {
60 pub anchor: ContentPosition,
61 pub cursor: ContentPosition,
62}
63
64impl PreviewSelection {
65 pub fn ordered(&self) -> (ContentPosition, ContentPosition) {
66 if self.anchor.row < self.cursor.row
67 || (self.anchor.row == self.cursor.row && self.anchor.col <= self.cursor.col)
68 {
69 (self.anchor, self.cursor)
70 } else {
71 (self.cursor, self.anchor)
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct StyledPreviewSegment {
78 pub text: String,
79 pub style: Style,
80}
81
82pub type StyledPreviewLine = Vec<StyledPreviewSegment>;
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum PreviewLineChange {
86 Added,
87 Deleted,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PreviewSearch {
92 pub query: String,
93 pub case_sensitive: bool,
94 pub current_match_index: usize,
95 pub match_positions: Vec<(usize, usize, usize)>, }
97
98impl Default for PreviewSearch {
99 fn default() -> Self {
100 Self {
101 query: String::new(),
102 case_sensitive: false,
103 current_match_index: 0,
104 match_positions: Vec::new(),
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct PreviewRenderCacheKey {
111 pub epoch: u64,
112 pub inner_width: u16,
113 pub show_line_numbers: bool,
114 pub wrap_enabled: bool,
115 pub content_hash: u64,
116 pub styled_lines_hash: u64,
117 pub line_changes_hash: u64,
118}
119
120#[derive(Debug, Clone)]
121pub struct PreviewRenderCache {
122 pub key: PreviewRenderCacheKey,
123 pub rendered_lines: Vec<Line<'static>>,
124 pub rendered_row_changes: Vec<Option<PreviewLineChange>>,
125 pub total_lines: usize,
126 pub line_number_cols: usize,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct PreviewDiffCacheKey {
131 pub path: PathBuf,
132 pub current_content_hash: u64,
133 pub content_type: ContentType,
134 pub language_id: Option<String>,
135 pub truncated: bool,
136}
137
138#[derive(Debug, Clone)]
139pub struct PreviewDiffCache {
140 pub key: PreviewDiffCacheKey,
141 pub merged_doc: PreviewDocument,
142}
143
144#[derive(Debug, Clone)]
145pub struct PreviewDocument {
146 pub source_path: PathBuf,
147 pub load_state: LoadState,
148 pub content_type: ContentType,
149 pub image_preview: bool,
150 pub image_preview_pending: bool,
151 pub language_id: Option<String>,
152 pub content_excerpt: String,
153 pub styled_lines: Vec<StyledPreviewLine>,
154 pub display_line_numbers: Vec<Option<usize>>,
155 pub line_changes: Vec<Option<PreviewLineChange>>,
156 pub fallback_reason: Option<PreviewFallbackReason>,
157 pub truncated: bool,
158 pub error_message: Option<String>,
159}
160
161impl Default for PreviewDocument {
162 fn default() -> Self {
163 Self {
164 source_path: PathBuf::new(),
165 load_state: LoadState::Idle,
166 content_type: ContentType::PlainText,
167 image_preview: false,
168 image_preview_pending: false,
169 language_id: None,
170 content_excerpt: String::new(),
171 styled_lines: Vec::new(),
172 display_line_numbers: Vec::new(),
173 line_changes: Vec::new(),
174 fallback_reason: None,
175 truncated: false,
176 error_message: None,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum FocusPane {
183 Tree,
184 Preview,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct SelectedEntryMetadata {
189 pub filename: String,
190 pub size_text: String,
191 pub permission_text: String,
192 pub modified_text: String,
193 pub hidden_text: String,
194}
195
196impl Default for SelectedEntryMetadata {
197 fn default() -> Self {
198 Self {
199 filename: "-".to_string(),
200 size_text: "-".to_string(),
201 permission_text: "-".to_string(),
202 modified_text: "-".to_string(),
203 hidden_text: "off".to_string(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct LayoutRegions {
210 pub top_directory_header: bool,
211 pub left_navigation_panel: bool,
212 pub right_preview_panel: bool,
213 pub preview_top_status_bar: bool,
214 pub bottom_global_status_bar: bool,
215}
216
217impl Default for LayoutRegions {
218 fn default() -> Self {
219 Self {
220 top_directory_header: true,
221 left_navigation_panel: true,
222 right_preview_panel: true,
223 preview_top_status_bar: true,
224 bottom_global_status_bar: true,
225 }
226 }
227}
228
229#[derive(Debug, Clone)]
230pub struct SessionState {
231 pub root_path: PathBuf,
232 pub current_path: PathBuf,
233 pub selected_index: usize,
234 pub selected_path: PathBuf,
235 pub selected_changed_at: Instant,
236 pub focus_pane: FocusPane,
237 pub status_message: String,
238 pub current_dir_error: Option<String>,
239 pub last_preview_latency_ms: u128,
240 pub last_child_path: Option<PathBuf>,
241 pub show_hidden: bool,
242 pub selected_metadata: SelectedEntryMetadata,
243 pub layout_regions: LayoutRegions,
244 pub preview_width_cols: u16,
245 pub tree_min_width_cols: u16,
246 pub preview_min_width_cols: u16,
247 pub preview_resize_step_cols: u16,
248 pub preview_scroll_row: usize,
249 pub preview_show_line_numbers: bool,
250 pub preview_wrap_enabled: bool,
251 pub preview_fullscreen: bool,
252 pub divider_drag_active: bool,
253 pub divider_drag_column: Option<u16>,
254 pub preview_scroll_col: usize,
255 pub preview_selection: Option<PreviewSelection>,
256 pub preview_selecting: bool,
257 pub preview_scrollbar_dragging: bool,
258 pub preview_inner_rect: (u16, u16, u16, u16),
259 pub preview_line_number_cols: usize,
260 pub preview_render_epoch: u64,
261 pub preview_render_cache: Option<PreviewRenderCache>,
262 pub preview_diff_cache: Option<PreviewDiffCache>,
263 pub preview_copy_indicator: bool,
264 pub preview_copying_indicator: bool,
265 pub preview_diff_mode: bool,
266 pub preview_search: Option<PreviewSearch>,
267 pub preview_search_input_active: bool,
268 pub help_overlay_visible: bool,
269 pub status_display_mode: StatusDisplayMode,
270 pub git_status: Option<GitRepoStatus>,
271 pub deferred_input_event: Option<Event>,
272}
273
274impl SessionState {
275 const DEFAULT_TREE_WIDTH_DENOMINATOR: u16 = 6;
276 const DEFAULT_PANEL_MIN_WIDTH: u16 = 20;
277 const DEFAULT_RESIZE_STEP: u16 = 2;
278
279 pub fn new(root_path: PathBuf) -> Self {
280 Self {
281 root_path: root_path.clone(),
282 current_path: root_path.clone(),
283 selected_index: 0,
284 selected_path: root_path,
285 selected_changed_at: Instant::now(),
286 focus_pane: FocusPane::Tree,
287 status_message: String::new(),
288 current_dir_error: None,
289 last_preview_latency_ms: 0,
290 last_child_path: None,
291 show_hidden: false,
292 selected_metadata: SelectedEntryMetadata::default(),
293 layout_regions: LayoutRegions::default(),
294 preview_width_cols: 0,
295 tree_min_width_cols: Self::DEFAULT_PANEL_MIN_WIDTH,
296 preview_min_width_cols: Self::DEFAULT_PANEL_MIN_WIDTH,
297 preview_resize_step_cols: Self::DEFAULT_RESIZE_STEP,
298 preview_scroll_row: 0,
299 preview_show_line_numbers: true,
300 preview_wrap_enabled: false,
301 preview_fullscreen: false,
302 divider_drag_active: false,
303 divider_drag_column: None,
304 preview_scroll_col: 0,
305 preview_selection: None,
306 preview_selecting: false,
307 preview_scrollbar_dragging: false,
308 preview_inner_rect: (0, 0, 0, 0),
309 preview_line_number_cols: 0,
310 preview_render_epoch: 0,
311 preview_render_cache: None,
312 preview_diff_cache: None,
313 preview_copy_indicator: false,
314 preview_copying_indicator: false,
315 preview_diff_mode: false,
316 preview_search: None,
317 preview_search_input_active: false,
318 help_overlay_visible: false,
319 status_display_mode: StatusDisplayMode::Bar,
320 git_status: None,
321 deferred_input_event: None,
322 }
323 }
324
325 pub fn normalize_preview_width(&mut self, main_width: u16) {
326 self.preview_width_cols = self.effective_preview_width(main_width);
327 }
328
329 pub fn panel_widths(&self, main_width: u16) -> (u16, u16) {
330 let preview = self.effective_preview_width(main_width);
331 let tree = main_width.saturating_sub(preview);
332 (tree, preview)
333 }
334
335 pub fn resize_step(&self) -> u16 {
336 self.preview_resize_step_cols.max(1)
337 }
338
339 pub fn reset_preview_scroll(&mut self) {
340 self.preview_scroll_row = 0;
341 self.preview_scroll_col = 0;
342 self.preview_selection = None;
343 self.preview_selecting = false;
344 self.preview_scrollbar_dragging = false;
345 }
346
347 pub fn clamp_preview_scroll(&mut self, total_lines: usize, viewport_rows: usize) {
348 let max_scroll = max_scroll_row(total_lines, viewport_rows);
349 if self.preview_scroll_row > max_scroll {
350 self.preview_scroll_row = max_scroll;
351 }
352 }
353
354 pub fn scroll_preview_lines(
355 &mut self,
356 delta: isize,
357 total_lines: usize,
358 viewport_rows: usize,
359 ) -> bool {
360 let before = self.preview_scroll_row;
361 let max_scroll = max_scroll_row(total_lines, viewport_rows);
362 if delta < 0 {
363 self.preview_scroll_row = self.preview_scroll_row.saturating_sub((-delta) as usize);
364 } else if delta > 0 {
365 self.preview_scroll_row = self
366 .preview_scroll_row
367 .saturating_add(delta as usize)
368 .min(max_scroll);
369 }
370 self.preview_scroll_row != before
371 }
372
373 pub fn scroll_preview_cols(
374 &mut self,
375 delta: isize,
376 max_width: usize,
377 viewport_cols: usize,
378 ) -> bool {
379 let before = self.preview_scroll_col;
380 let max_scroll = max_width.saturating_sub(viewport_cols);
381 if delta < 0 {
382 self.preview_scroll_col = self.preview_scroll_col.saturating_sub((-delta) as usize);
383 } else {
384 self.preview_scroll_col = self
385 .preview_scroll_col
386 .saturating_add(delta as usize)
387 .min(max_scroll);
388 }
389 self.preview_scroll_col != before
390 }
391
392 pub fn page_scroll_preview_down(&mut self, total_lines: usize, viewport_rows: usize) -> bool {
393 let page = viewport_rows.max(1) as isize;
394 self.scroll_preview_lines(page, total_lines, viewport_rows)
395 }
396
397 pub fn page_scroll_preview_up(&mut self, total_lines: usize, viewport_rows: usize) -> bool {
398 let page = viewport_rows.max(1) as isize;
399 self.scroll_preview_lines(-page, total_lines, viewport_rows)
400 }
401
402 pub fn resize_preview_by(&mut self, delta_cols: i16, main_width: u16) {
403 let base = i32::from(self.effective_preview_width(main_width));
404 let desired = (base + i32::from(delta_cols)).max(0) as u16;
405 self.preview_width_cols = self.clamped_preview_width(main_width, desired);
406 }
407
408 pub fn set_preview_width_from_divider(&mut self, divider_col: u16, main_width: u16) {
409 let tree = divider_col.min(main_width);
410 let desired_preview = main_width.saturating_sub(tree);
411 self.preview_width_cols = self.clamped_preview_width(main_width, desired_preview);
412 }
413
414 pub fn clamped_divider_column(&self, divider_col: u16, main_width: u16) -> u16 {
415 let tree = divider_col.min(main_width);
416 let desired_preview = main_width.saturating_sub(tree);
417 let preview = self.clamped_preview_width(main_width, desired_preview);
418 main_width.saturating_sub(preview)
419 }
420
421 pub fn divider_column(&self, main_width: u16) -> u16 {
422 let (tree, _) = self.panel_widths(main_width);
423 tree
424 }
425
426 fn effective_preview_width(&self, main_width: u16) -> u16 {
427 let desired = if self.preview_width_cols == 0 {
428 let default_tree_width = main_width / Self::DEFAULT_TREE_WIDTH_DENOMINATOR;
429 main_width.saturating_sub(default_tree_width)
430 } else {
431 self.preview_width_cols
432 };
433 self.clamped_preview_width(main_width, desired)
434 }
435
436 fn clamped_preview_width(&self, main_width: u16, desired: u16) -> u16 {
437 if main_width == 0 {
438 return 0;
439 }
440
441 let preview_min = self.preview_min_width_cols.min(main_width);
442 let tree_min = self
443 .tree_min_width_cols
444 .min(main_width.saturating_sub(preview_min));
445 let preview_max = main_width.saturating_sub(tree_min).max(preview_min);
446 desired.clamp(preview_min, preview_max)
447 }
448
449 pub fn revalidate_selection(&mut self, nodes: &[TreeNode]) {
450 if nodes.is_empty() {
451 self.selected_index = 0;
452 return;
453 }
454 if self.selected_index >= nodes.len() {
455 self.selected_index = nodes.len().saturating_sub(1);
456 }
457 }
458
459 pub fn restore_or_default_selection(
460 &mut self,
461 nodes: &[TreeNode],
462 preferred: Option<&PathBuf>,
463 ) {
464 if nodes.is_empty() {
465 self.selected_index = 0;
466 return;
467 }
468
469 if let Some(path) = preferred {
470 if let Some(idx) = nodes.iter().position(|n| &n.path == path) {
471 self.selected_index = idx;
472 return;
473 }
474 }
475
476 self.selected_index = 0;
477 }
478
479 pub fn update_selected_path(&mut self, nodes: &[TreeNode]) {
480 let next = nodes
481 .get(self.selected_index)
482 .map(|n| n.path.clone())
483 .unwrap_or_else(|| self.current_path.clone());
484 self.set_selected_path(next);
485 }
486
487 pub fn set_selected_path(&mut self, path: PathBuf) {
488 if self.selected_path != path {
489 self.selected_path = path;
490 self.selected_changed_at = Instant::now();
491 }
492 }
493
494 pub fn set_current_dir_error(&mut self, message: impl Into<String>) {
495 self.current_dir_error = Some(message.into());
496 }
497
498 pub fn clear_current_dir_error(&mut self) {
499 self.current_dir_error = None;
500 }
501}
502
503fn max_scroll_row(total_lines: usize, viewport_rows: usize) -> usize {
504 total_lines.saturating_sub(viewport_rows.max(1))
505}