tenseleyflow/fussr / 2da7494

Browse files

test commit

Authored by espadonne
SHA
2da74943a4a7f4aa90e3d7a12e19932bdb5352fe
Parents
5212888
Tree
66707ef

1 changed file

StatusFile+-
A app.rs 664 0
app.rsadded
@@ -0,0 +1,664 @@
1
+use crate::error::Result;
2
+use crate::git::GitRepo;
3
+use crate::tree::{build_tree, flatten_tree, toggle_expanded};
4
+use crate::types::{AppMode, FileEntry, InputMode, SelectableItem, TreeNode};
5
+use std::path::PathBuf;
6
+use std::time::Instant;
7
+
8
+/// Main application state
9
+pub struct App {
10
+    /// Git repository
11
+    pub repo: GitRepo,
12
+    /// Repository name
13
+    pub repo_name: String,
14
+    /// Current branch name
15
+    pub branch_name: String,
16
+    /// Whether to show all files or just dirty ones
17
+    pub show_all: bool,
18
+    /// Whether to hide dotfiles
19
+    pub hide_dotfiles: bool,
20
+    /// Tree root
21
+    pub tree: TreeNode,
22
+    /// Flattened items for display
23
+    pub items: Vec<SelectableItem>,
24
+    /// Currently selected item index
25
+    pub selected: usize,
26
+    /// Viewport offset for scrolling
27
+    pub viewport_offset: usize,
28
+    /// Application mode (Normal or Git)
29
+    pub mode: AppMode,
30
+    /// Input mode (Navigation, Rename, Search, etc.)
31
+    pub input_mode: InputMode,
32
+    /// Whether the app should quit
33
+    pub should_quit: bool,
34
+    /// Status message to display
35
+    pub status_message: Option<String>,
36
+    /// Fuzzy search buffer
37
+    pub search_buffer: String,
38
+    /// Last search keystroke time (for timeout)
39
+    pub last_search_time: Option<Instant>,
40
+}
41
+
42
+
43
+impl App {
44
+    /// Create a new application instance
45
+    pub fn new(show_all: bool) -> Result<Self> {
46
+        let repo = GitRepo::open()?;
47
+        let repo_name = repo.repo_name();
48
+        let branch_name = repo.branch_name();
49
+
50
+        let mut app = Self {
51
+            repo,
52
+            repo_name,
53
+            branch_name,
54
+            show_all,
55
+            hide_dotfiles: false,
56
+            tree: TreeNode::root(),
57
+            items: Vec::new(),
58
+            selected: 0,
59
+            viewport_offset: 0,
60
+            mode: AppMode::Normal,
61
+            input_mode: InputMode::Navigation,
62
+            should_quit: false,
63
+            status_message: None,
64
+            search_buffer: String::new(),
65
+            last_search_time: None,
66
+        };
67
+
68
+        app.refresh_files()?;
69
+        Ok(app)
70
+    }
71
+
72
+    /// Refresh file list and rebuild tree
73
+    pub fn refresh_files(&mut self) -> Result<()> {
74
+        let mut files = if self.show_all {
75
+            self.repo.get_all_files()?
76
+        } else {
77
+            self.repo.get_dirty_files()?
78
+        };
79
+
80
+        // Mark incoming changes
81
+        let _ = self.repo.mark_incoming_changes(&mut files);
82
+
83
+        self.rebuild_tree(&files);
84
+        self.update_branch_info();
85
+        Ok(())
86
+    }
87
+
88
+    /// Rebuild tree from file entries, preserving expanded state
89
+    fn rebuild_tree(&mut self, files: &[FileEntry]) {
90
+        // Save collapsed paths
91
+        let collapsed_paths = self.get_collapsed_paths();
92
+
93
+        // Build new tree
94
+        self.tree = build_tree(files, self.hide_dotfiles);
95
+
96
+        // Restore collapsed state
97
+        self.restore_collapsed_paths(&collapsed_paths);
98
+
99
+        // Flatten for display
100
+        self.items = flatten_tree(&self.tree, self.hide_dotfiles);
101
+
102
+        // Adjust selection if needed
103
+        if self.selected >= self.items.len() && !self.items.is_empty() {
104
+            self.selected = self.items.len() - 1;
105
+        }
106
+    }
107
+
108
+    /// Get paths of all collapsed directories
109
+    fn get_collapsed_paths(&self) -> Vec<PathBuf> {
110
+        let mut paths = Vec::new();
111
+        Self::collect_collapsed(&self.tree, &mut paths);
112
+        paths
113
+    }
114
+
115
+    fn collect_collapsed(node: &TreeNode, paths: &mut Vec<PathBuf>) {
116
+        if !node.is_file && !node.is_expanded && node.name != "." {
117
+            paths.push(node.full_path.clone());
118
+        }
119
+        for child in &node.children {
120
+            Self::collect_collapsed(child, paths);
121
+        }
122
+    }
123
+
124
+    /// Restore collapsed state from saved paths
125
+    fn restore_collapsed_paths(&mut self, paths: &[PathBuf]) {
126
+        for path in paths {
127
+            Self::set_collapsed(&mut self.tree, path);
128
+        }
129
+    }
130
+
131
+    fn set_collapsed(node: &mut TreeNode, path: &PathBuf) {
132
+        if &node.full_path == path {
133
+            node.is_expanded = false;
134
+            return;
135
+        }
136
+        for child in &mut node.children {
137
+            Self::set_collapsed(child, path);
138
+        }
139
+    }
140
+
141
+    /// Update branch info
142
+    pub fn update_branch_info(&mut self) {
143
+        self.repo_name = self.repo.repo_name();
144
+        self.branch_name = self.repo.branch_name();
145
+    }
146
+
147
+    /// Get currently selected item
148
+    pub fn selected_item(&self) -> Option<&SelectableItem> {
149
+        self.items.get(self.selected)
150
+    }
151
+
152
+    /// Navigate down in the list
153
+    pub fn navigate_down(&mut self) {
154
+        if self.items.is_empty() {
155
+            return;
156
+        }
157
+        if self.selected + 1 < self.items.len() {
158
+            self.selected += 1;
159
+        }
160
+    }
161
+
162
+    /// Navigate up in the list
163
+    pub fn navigate_up(&mut self) {
164
+        if self.items.is_empty() {
165
+            return;
166
+        }
167
+        if self.selected > 0 {
168
+            self.selected -= 1;
169
+        }
170
+    }
171
+
172
+    /// Navigate left (collapse current directory or go to parent)
173
+    pub fn navigate_left(&mut self) {
174
+        if self.items.is_empty() {
175
+            return;
176
+        }
177
+
178
+        let item = &self.items[self.selected];
179
+
180
+        // If it's an expanded directory, collapse it
181
+        if !item.is_file && item.is_expanded {
182
+            self.toggle_selected();
183
+            return;
184
+        }
185
+
186
+        // Otherwise, go to parent directory
187
+        let current_depth = item.depth;
188
+        if current_depth == 0 {
189
+            return; // Already at root level
190
+        }
191
+
192
+        // Find parent (previous item at depth - 1)
193
+        for i in (0..self.selected).rev() {
194
+            if self.items[i].depth == current_depth - 1 {
195
+                self.selected = i;
196
+                return;
197
+            }
198
+        }
199
+    }
200
+
201
+    /// Navigate right (expand directory or go into it)
202
+    pub fn navigate_right(&mut self) {
203
+        if self.items.is_empty() {
204
+            return;
205
+        }
206
+
207
+        let item = &self.items[self.selected];
208
+        if item.is_file {
209
+            return; // Can't enter a file
210
+        }
211
+
212
+        // If collapsed, expand
213
+        if !item.is_expanded {
214
+            self.toggle_selected();
215
+            return;
216
+        }
217
+
218
+        // If expanded, move to first child
219
+        let target_depth = item.depth + 1;
220
+        for i in (self.selected + 1)..self.items.len() {
221
+            if self.items[i].depth == target_depth {
222
+                self.selected = i;
223
+                return;
224
+            }
225
+        }
226
+    }
227
+
228
+    /// Toggle expanded state of selected directory
229
+    pub fn toggle_selected(&mut self) {
230
+        if let Some(item) = self.items.get(self.selected) {
231
+            if !item.is_file {
232
+                let path = item.path.clone();
233
+                if toggle_expanded(&mut self.tree, &path) {
234
+                    self.items = flatten_tree(&self.tree, self.hide_dotfiles);
235
+                    // Keep selection valid
236
+                    if self.selected >= self.items.len() && !self.items.is_empty() {
237
+                        self.selected = self.items.len() - 1;
238
+                    }
239
+                }
240
+            }
241
+        }
242
+    }
243
+
244
+    /// Toggle dotfile visibility
245
+    pub fn toggle_dotfiles(&mut self) {
246
+        self.hide_dotfiles = !self.hide_dotfiles;
247
+        self.items = flatten_tree(&self.tree, self.hide_dotfiles);
248
+        if self.selected >= self.items.len() && !self.items.is_empty() {
249
+            self.selected = self.items.len() - 1;
250
+        }
251
+    }
252
+
253
+    /// Toggle app mode (Normal <-> Git)
254
+    pub fn toggle_mode(&mut self) {
255
+        self.mode.toggle();
256
+    }
257
+
258
+    /// Stage selected file or directory
259
+    pub fn stage_selected(&mut self) -> Result<()> {
260
+        if let Some(item) = self.selected_item().cloned() {
261
+            if item.is_file {
262
+                if item.status.is_unstaged || item.status.is_untracked {
263
+                    self.repo.stage_file(&item.path)?;
264
+                    self.set_status(format!("Staged: {}", item.path.display()));
265
+                }
266
+            } else {
267
+                self.repo.stage_directory(&item.path)?;
268
+                self.set_status(format!("Staged directory: {}", item.path.display()));
269
+            }
270
+            self.refresh_files()?;
271
+        }
272
+        Ok(())
273
+    }
274
+
275
+    /// Unstage selected file
276
+    pub fn unstage_selected(&mut self) -> Result<()> {
277
+        if let Some(item) = self.selected_item().cloned() {
278
+            if item.is_file && item.status.is_staged {
279
+                self.repo.unstage_file(&item.path)?;
280
+                self.set_status(format!("Unstaged: {}", item.path.display()));
281
+                self.refresh_files()?;
282
+            }
283
+        }
284
+        Ok(())
285
+    }
286
+
287
+    /// Stage all files
288
+    pub fn stage_all(&mut self) -> Result<()> {
289
+        self.repo.stage_all()?;
290
+        self.set_status("Staged all changes".to_string());
291
+        self.refresh_files()
292
+    }
293
+
294
+    /// Unstage all files
295
+    pub fn unstage_all(&mut self) -> Result<()> {
296
+        self.repo.unstage_all()?;
297
+        self.set_status("Unstaged all files".to_string());
298
+        self.refresh_files()
299
+    }
300
+
301
+    /// Discard changes to selected file
302
+    pub fn discard_selected(&mut self) -> Result<()> {
303
+        if let Some(item) = self.selected_item().cloned() {
304
+            if item.is_file && item.status.is_dirty() {
305
+                self.repo.discard_changes(&item.path, item.status.is_untracked)?;
306
+                self.set_status(format!("Discarded changes: {}", item.path.display()));
307
+                self.refresh_files()?;
308
+            }
309
+        }
310
+        Ok(())
311
+    }
312
+
313
+    /// Delete selected file
314
+    pub fn delete_selected(&mut self) -> Result<()> {
315
+        if let Some(item) = self.selected_item().cloned() {
316
+            if item.is_file {
317
+                self.repo.delete_file(&item.path, item.status.is_untracked)?;
318
+                self.set_status(format!("Deleted: {}", item.path.display()));
319
+                self.refresh_files()?;
320
+            }
321
+        }
322
+        Ok(())
323
+    }
324
+
325
+    /// Fetch from remote
326
+    pub fn fetch(&mut self) -> Result<()> {
327
+        self.set_status("Fetching...".to_string());
328
+        self.repo.fetch()?;
329
+        self.set_status("Fetch complete".to_string());
330
+        self.refresh_files()
331
+    }
332
+
333
+    /// Pull from remote
334
+    pub fn pull(&mut self) -> Result<()> {
335
+        self.set_status("Pulling...".to_string());
336
+        self.repo.pull()?;
337
+        self.set_status("Pull complete".to_string());
338
+        self.refresh_files()
339
+    }
340
+
341
+    /// Push to remote
342
+    pub fn push(&mut self) -> Result<()> {
343
+        self.set_status("Pushing...".to_string());
344
+        self.repo.push()?;
345
+        self.set_status("Push complete".to_string());
346
+        Ok(())
347
+    }
348
+
349
+    /// Create a commit
350
+    pub fn commit(&mut self, message: &str) -> Result<()> {
351
+        self.repo.commit(message)?;
352
+        self.set_status("Committed successfully".to_string());
353
+        self.refresh_files()
354
+    }
355
+
356
+    /// Amend the last commit
357
+    pub fn commit_amend(&mut self, message: &str) -> Result<()> {
358
+        self.repo.commit_amend(message)?;
359
+        self.set_status("Commit amended".to_string());
360
+        self.refresh_files()
361
+    }
362
+
363
+    /// Get last commit message
364
+    pub fn last_commit_message(&self) -> Option<String> {
365
+        self.repo.last_commit_message()
366
+    }
367
+
368
+    /// Set status message
369
+    pub fn set_status(&mut self, message: String) {
370
+        self.status_message = Some(message);
371
+    }
372
+
373
+    /// Clear status message
374
+    pub fn clear_status(&mut self) {
375
+        self.status_message = None;
376
+    }
377
+
378
+    /// Enter rename mode for selected item
379
+    pub fn enter_rename_mode(&mut self) {
380
+        if let Some(item) = self.selected_item() {
381
+            self.input_mode = InputMode::Rename {
382
+                buffer: item.name.clone(),
383
+                cursor: item.name.len(),
384
+            };
385
+        }
386
+    }
387
+
388
+    /// Exit rename mode and apply rename
389
+    pub fn apply_rename(&mut self) -> Result<()> {
390
+        if let InputMode::Rename { buffer, .. } = &self.input_mode {
391
+            if let Some(item) = self.selected_item().cloned() {
392
+                let new_name = buffer.trim();
393
+                if !new_name.is_empty() && new_name != item.name {
394
+                    let new_path = item.path.with_file_name(new_name);
395
+                    self.repo.rename_file(&item.path, &new_path)?;
396
+                    self.set_status(format!("Renamed to: {}", new_name));
397
+                    self.refresh_files()?;
398
+                }
399
+            }
400
+        }
401
+        self.input_mode = InputMode::Navigation;
402
+        Ok(())
403
+    }
404
+
405
+    /// Cancel rename mode
406
+    pub fn cancel_rename(&mut self) {
407
+        self.input_mode = InputMode::Navigation;
408
+    }
409
+
410
+    /// Enter commit mode
411
+    pub fn enter_commit_mode(&mut self, amend: bool) {
412
+        let initial_buffer = if amend {
413
+            self.repo.last_commit_message().unwrap_or_default()
414
+        } else {
415
+            String::new()
416
+        };
417
+        let cursor = initial_buffer.len();
418
+        self.input_mode = InputMode::Commit {
419
+            buffer: initial_buffer,
420
+            cursor,
421
+            amend,
422
+            status: crate::types::CommitStatus::Editing,
423
+        };
424
+    }
425
+
426
+    /// Start the commit process (show "Committing..." state)
427
+    pub fn start_commit(&mut self) {
428
+        if let InputMode::Commit { status, .. } = &mut self.input_mode {
429
+            *status = crate::types::CommitStatus::Committing;
430
+        }
431
+    }
432
+
433
+    /// Apply commit and update status
434
+    pub fn apply_commit(&mut self) -> Result<()> {
435
+        // Extract values we need before modifying
436
+        let (message, amend) = if let InputMode::Commit { buffer, amend, .. } = &self.input_mode {
437
+            (buffer.trim().to_string(), *amend)
438
+        } else {
439
+            return Ok(());
440
+        };
441
+
442
+        if message.is_empty() {
443
+            self.input_mode = InputMode::Navigation;
444
+            return Ok(());
445
+        }
446
+
447
+        // Perform the commit
448
+        let result = if amend {
449
+            self.repo.commit_amend(&message)
450
+        } else {
451
+            self.repo.commit(&message)
452
+        };
453
+
454
+        // Update status based on result
455
+        match result {
456
+            Ok(()) => {
457
+                if let InputMode::Commit { status, .. } = &mut self.input_mode {
458
+                    *status = crate::types::CommitStatus::Success;
459
+                }
460
+                self.set_status("Committed".to_string());
461
+                self.refresh_files()?;
462
+            }
463
+            Err(_) => {
464
+                if let InputMode::Commit { status, .. } = &mut self.input_mode {
465
+                    *status = crate::types::CommitStatus::Failed;
466
+                }
467
+            }
468
+        }
469
+
470
+        Ok(())
471
+    }
472
+
473
+    /// Close commit modal and return to navigation
474
+    pub fn close_commit(&mut self) {
475
+        self.input_mode = InputMode::Navigation;
476
+    }
477
+
478
+    /// Cancel commit mode
479
+    pub fn cancel_commit(&mut self) {
480
+        self.input_mode = InputMode::Navigation;
481
+    }
482
+
483
+    /// Check if search timeout has elapsed (0.5 seconds) and reset if needed
484
+    pub fn check_search_timeout(&mut self) {
485
+        if let Some(last_time) = self.last_search_time {
486
+            if last_time.elapsed().as_millis() > 500 {
487
+                self.search_buffer.clear();
488
+                self.last_search_time = None;
489
+            }
490
+        }
491
+    }
492
+
493
+    /// Add a character to the search buffer and jump to match
494
+    pub fn search_add_char(&mut self, c: char) {
495
+        // Check timeout first - if elapsed, start fresh
496
+        self.check_search_timeout();
497
+
498
+        // Add character to buffer
499
+        if self.search_buffer.len() < 32 {
500
+            self.search_buffer.push(c);
501
+            self.last_search_time = Some(Instant::now());
502
+
503
+            // Jump to best match
504
+            self.fuzzy_jump_to_match();
505
+        }
506
+    }
507
+
508
+    /// Remove last character from search buffer
509
+    pub fn search_backspace(&mut self) {
510
+        if !self.search_buffer.is_empty() {
511
+            self.search_buffer.pop();
512
+            self.last_search_time = Some(Instant::now());
513
+
514
+            if !self.search_buffer.is_empty() {
515
+                self.fuzzy_jump_to_match();
516
+            }
517
+        }
518
+    }
519
+
520
+    /// Clear the search buffer
521
+    pub fn clear_search(&mut self) {
522
+        self.search_buffer.clear();
523
+        self.last_search_time = None;
524
+    }
525
+
526
+    /// Jump to the best matching item using fzf-style scoring
527
+    fn fuzzy_jump_to_match(&mut self) {
528
+        if self.search_buffer.is_empty() || self.items.is_empty() {
529
+            return;
530
+        }
531
+
532
+        let pattern = self.search_buffer.to_lowercase();
533
+        let mut best_idx = self.selected;
534
+        let mut best_score: i32 = 0;
535
+
536
+        // Check current item first - if exact match, stay on it
537
+        if let Some(item) = self.items.get(self.selected) {
538
+            let current_score = fuzzy_match_score(&pattern, &item.name.to_lowercase());
539
+            if current_score >= 10000 {
540
+                return; // Exact match - stay here
541
+            }
542
+            best_score = current_score;
543
+        }
544
+
545
+        // PASS 1: Search for basename matches (file/folder names)
546
+        for (i, item) in self.items.iter().enumerate() {
547
+            if i == self.selected {
548
+                continue;
549
+            }
550
+            let score = fuzzy_match_score(&pattern, &item.name.to_lowercase());
551
+            if score > best_score {
552
+                best_score = score;
553
+                best_idx = i;
554
+            }
555
+        }
556
+
557
+        // If we found a good basename match (prefix or exact), use it
558
+        if best_score >= 5000 {
559
+            self.selected = best_idx;
560
+            return;
561
+        }
562
+
563
+        // PASS 2: Search full paths if no good basename match
564
+        for (i, item) in self.items.iter().enumerate() {
565
+            if i == self.selected {
566
+                continue;
567
+            }
568
+            let path_str = item.path.to_string_lossy().to_lowercase();
569
+            let score = fuzzy_match_score(&pattern, &path_str);
570
+            if score > best_score {
571
+                best_score = score;
572
+                best_idx = i;
573
+            }
574
+        }
575
+
576
+        // Jump to best match if any was found
577
+        if best_score > 0 {
578
+            self.selected = best_idx;
579
+        }
580
+    }
581
+}
582
+
583
+/// Fuzzy matching with fzf-style scoring
584
+/// Returns a score (higher is better), 0 means no match
585
+fn fuzzy_match_score(pattern: &str, text: &str) -> i32 {
586
+    if pattern.is_empty() {
587
+        return 1;
588
+    }
589
+
590
+    // Exact match (highest score)
591
+    if pattern == text {
592
+        return 10000;
593
+    }
594
+
595
+    // Prefix match (very high score)
596
+    if text.starts_with(pattern) {
597
+        return 5000;
598
+    }
599
+
600
+    // Fuzzy match with scoring
601
+    let pattern_chars: Vec<char> = pattern.chars().collect();
602
+    let text_chars: Vec<char> = text.chars().collect();
603
+
604
+    let mut score: i32 = 0;
605
+    let mut pattern_idx = 0;
606
+    let mut consecutive_bonus: i32 = 0;
607
+    let mut is_consecutive = false;
608
+    let mut match_started = false;
609
+
610
+    for (text_idx, &c) in text_chars.iter().enumerate() {
611
+        if pattern_idx >= pattern_chars.len() {
612
+            break;
613
+        }
614
+
615
+        if c == pattern_chars[pattern_idx] {
616
+            match_started = true;
617
+
618
+            // Base score for each matched character
619
+            score += 100;
620
+
621
+            // Bonus for consecutive characters
622
+            if is_consecutive {
623
+                consecutive_bonus += 1;
624
+                score += consecutive_bonus * 50;
625
+            } else {
626
+                consecutive_bonus = 1;
627
+                is_consecutive = true;
628
+            }
629
+
630
+            // Bonus for matching at start of text
631
+            if text_idx == 0 {
632
+                score += 200;
633
+            }
634
+
635
+            // Bonus for matching after separator (word boundary)
636
+            if text_idx > 0 {
637
+                let prev = text_chars[text_idx - 1];
638
+                if prev == '/' || prev == '_' || prev == '-' || prev == '.' {
639
+                    score += 150;
640
+                }
641
+            }
642
+
643
+            pattern_idx += 1;
644
+        } else {
645
+            // Reset consecutive bonus
646
+            is_consecutive = false;
647
+            consecutive_bonus = 0;
648
+            // Small penalty for gaps
649
+            if match_started {
650
+                score -= 1;
651
+            }
652
+        }
653
+    }
654
+
655
+    // No match if we didn't find all pattern characters
656
+    if pattern_idx < pattern_chars.len() {
657
+        return 0;
658
+    }
659
+
660
+    // Penalty for longer strings (prefer concise matches)
661
+    score -= text.len() as i32;
662
+
663
+    score.max(1) // Ensure positive score if we matched
664
+}