tenseleyflow/fackr / 99366a9

Browse files

feat: fuss fuzzy filter and references panel visual fix

- Add fuzzy filter to fuss mode: type to jump to matching files
- Filter auto-resets after 500ms of inactivity
- Move git operations behind Alt+G prefix (git sub-mode)
- Move toggle hidden behind Alt+.
- Fix references panel row padding calculation
- Add ResetColor after panel rows to prevent color bleed
- Change code completion keybinding from Ctrl+Space to Ctrl+N
Authored by espadonne
SHA
99366a9c2b9a6e8f3404b07ed13eb728ed26c70a
Parents
422bf81
Tree
996558d

3 changed files

StatusFile+-
M src/editor/state.rs 63 26
M src/fuss/state.rs 100 0
M src/render/screen.rs 37 6
src/editor/state.rsmodified
@@ -485,8 +485,8 @@ impl Editor {
485485
             }
486486
         }
487487
 
488
-        // Update diagnostics for current file
489
-        if let Some(path) = self.filename() {
488
+        // Update diagnostics for current file (need full path to match LSP URIs)
489
+        if let Some(path) = self.current_file_path() {
490490
             let path_str = path.to_string_lossy();
491491
             self.lsp_state.diagnostics = self.workspace.lsp.get_diagnostics(&path_str);
492492
         }
@@ -1049,6 +1049,7 @@ impl Editor {
10491049
                     self.workspace.fuss.hints_expanded,
10501050
                     &repo_name,
10511051
                     branch.as_deref(),
1052
+                    self.workspace.fuss.git_mode,
10521053
                 )?;
10531054
             }
10541055
         }
@@ -1466,8 +1467,8 @@ impl Editor {
14661467
             (Key::F(12), Modifiers { shift: true, .. }) => self.lsp_find_references(),
14671468
             // Hover info: F1
14681469
             (Key::F(1), _) => self.lsp_hover(),
1469
-            // Code completion: Ctrl+Space
1470
-            (Key::Char(' '), Modifiers { ctrl: true, .. }) => self.lsp_complete(),
1470
+            // Code completion: Ctrl+N (vim-style)
1471
+            (Key::Char('n'), Modifiers { ctrl: true, .. }) => self.lsp_complete(),
14711472
             // Rename: F2
14721473
             (Key::F(2), _) => self.lsp_rename(),
14731474
             // Server manager: Alt+M
@@ -3138,6 +3139,11 @@ impl Editor {
31383139
     }
31393140
 
31403141
     fn handle_fuss_key(&mut self, key: Key, mods: Modifiers) -> Result<()> {
3142
+        // Handle git mode separately
3143
+        if self.workspace.fuss.git_mode {
3144
+            return self.handle_fuss_git_key(key, mods);
3145
+        }
3146
+
31413147
         match (&key, &mods) {
31423148
             // Quit: Ctrl+Q (still works in fuss mode)
31433149
             (Key::Char('q'), Modifiers { ctrl: true, .. }) => {
@@ -3146,19 +3152,23 @@ impl Editor {
31463152
 
31473153
             // Exit fuss mode (Escape or F3)
31483154
             (Key::Escape, _) | (Key::F(3), _) => {
3155
+                self.workspace.fuss.filter_clear();
31493156
                 self.workspace.fuss.deactivate();
31503157
             }
31513158
 
31523159
             // Navigation
3153
-            (Key::Up, _) | (Key::Char('k'), Modifiers { ctrl: false, alt: false, .. }) => {
3160
+            (Key::Up, _) => {
3161
+                self.workspace.fuss.filter_clear();
31543162
                 self.workspace.fuss.move_up();
31553163
             }
3156
-            (Key::Down, _) | (Key::Char('j'), Modifiers { ctrl: false, alt: false, .. }) => {
3164
+            (Key::Down, _) => {
3165
+                self.workspace.fuss.filter_clear();
31573166
                 self.workspace.fuss.move_down();
31583167
             }
31593168
 
31603169
             // Toggle expand/collapse directory, or collapse parent if on a file/collapsed dir
31613170
             (Key::Char(' '), _) => {
3171
+                self.workspace.fuss.filter_clear();
31623172
                 if self.workspace.fuss.is_dir_selected() {
31633173
                     // If on a directory, toggle its expand state
31643174
                     self.workspace.fuss.toggle_expand();
@@ -3170,6 +3180,7 @@ impl Editor {
31703180
 
31713181
             // Expand directory (right arrow)
31723182
             (Key::Right, _) => {
3183
+                self.workspace.fuss.filter_clear();
31733184
                 if self.workspace.fuss.is_dir_selected() {
31743185
                     // Only expand if not already expanded
31753186
                     if let Some(ref tree) = self.workspace.fuss.tree {
@@ -3185,6 +3196,7 @@ impl Editor {
31853196
 
31863197
             // Collapse directory or go to parent (left arrow)
31873198
             (Key::Left, _) => {
3199
+                self.workspace.fuss.filter_clear();
31883200
                 let mut collapsed = false;
31893201
                 if self.workspace.fuss.is_dir_selected() {
31903202
                     // If on an expanded directory, collapse it
@@ -3205,7 +3217,8 @@ impl Editor {
32053217
             }
32063218
 
32073219
             // Open file or toggle directory
3208
-            (Key::Enter, _) | (Key::Char('o'), Modifiers { ctrl: false, alt: false, .. }) => {
3220
+            (Key::Enter, _) => {
3221
+                self.workspace.fuss.filter_clear();
32093222
                 if self.workspace.fuss.is_dir_selected() {
32103223
                     self.workspace.fuss.toggle_expand();
32113224
                 } else if let Some(path) = self.workspace.fuss.selected_file() {
@@ -3214,8 +3227,8 @@ impl Editor {
32143227
                 }
32153228
             }
32163229
 
3217
-            // Toggle hidden files
3218
-            (Key::Char('.'), _) => {
3230
+            // Toggle hidden files: Alt+.
3231
+            (Key::Char('.'), Modifiers { alt: true, .. }) => {
32193232
                 self.workspace.fuss.toggle_hidden();
32203233
             }
32213234
 
@@ -3228,13 +3241,8 @@ impl Editor {
32283241
                 self.workspace.fuss.toggle_hints();
32293242
             }
32303243
 
3231
-            // Also allow 'h' for hints toggle as fallback
3232
-            (Key::Char('h'), Modifiers { ctrl: false, alt: false, .. }) => {
3233
-                self.workspace.fuss.toggle_hints();
3234
-            }
3235
-
3236
-            // Open file in vertical split (v)
3237
-            (Key::Char('v'), Modifiers { ctrl: false, alt: false, .. }) => {
3244
+            // Open file in vertical split: Ctrl+V
3245
+            (Key::Char('v'), Modifiers { ctrl: true, .. }) => {
32383246
                 if !self.workspace.fuss.is_dir_selected() {
32393247
                     if let Some(path) = self.workspace.fuss.selected_file() {
32403248
                         self.open_file_in_vsplit(&path)?;
@@ -3243,8 +3251,8 @@ impl Editor {
32433251
                 }
32443252
             }
32453253
 
3246
-            // Open file in horizontal split (s)
3247
-            (Key::Char('s'), Modifiers { ctrl: false, alt: false, .. }) => {
3254
+            // Open file in horizontal split: Ctrl+S
3255
+            (Key::Char('s'), Modifiers { ctrl: true, .. }) => {
32483256
                 if !self.workspace.fuss.is_dir_selected() {
32493257
                     if let Some(path) = self.workspace.fuss.selected_file() {
32503258
                         self.open_file_in_hsplit(&path)?;
@@ -3253,8 +3261,36 @@ impl Editor {
32533261
                 }
32543262
             }
32553263
 
3264
+            // Enter git mode: Alt+G
3265
+            (Key::Char('g'), Modifiers { alt: true, .. }) => {
3266
+                self.workspace.fuss.enter_git_mode();
3267
+                self.message = Some("Git: [a]dd [u]nstage [d]iff [m]sg [p]ush pu[l]l [f]etch [t]ag".to_string());
3268
+            }
3269
+
3270
+            // Backspace: remove last filter character
3271
+            (Key::Backspace, _) => {
3272
+                self.workspace.fuss.filter_pop();
3273
+            }
3274
+
3275
+            // Regular characters: add to filter for fuzzy jump
3276
+            (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
3277
+                self.workspace.fuss.filter_push(*c);
3278
+            }
3279
+
3280
+            _ => {}
3281
+        }
3282
+        Ok(())
3283
+    }
3284
+
3285
+    /// Handle keys when in git sub-mode within fuss
3286
+    fn handle_fuss_git_key(&mut self, key: Key, mods: Modifiers) -> Result<()> {
3287
+        // Any key exits git mode (after potentially doing an action)
3288
+        self.workspace.fuss.exit_git_mode();
3289
+        self.message = None;
3290
+
3291
+        match (&key, &mods) {
32563292
             // Git: Stage file (a)
3257
-            (Key::Char('a'), Modifiers { ctrl: false, alt: false, .. }) => {
3293
+            (Key::Char('a'), _) => {
32583294
                 if self.workspace.fuss.stage_selected() {
32593295
                     self.message = Some("Staged".to_string());
32603296
                 } else {
@@ -3263,7 +3299,7 @@ impl Editor {
32633299
             }
32643300
 
32653301
             // Git: Unstage file (u)
3266
-            (Key::Char('u'), Modifiers { ctrl: false, alt: false, .. }) => {
3302
+            (Key::Char('u'), _) => {
32673303
                 if self.workspace.fuss.unstage_selected() {
32683304
                     self.message = Some("Unstaged".to_string());
32693305
                 } else {
@@ -3272,7 +3308,7 @@ impl Editor {
32723308
             }
32733309
 
32743310
             // Git: Show diff (d)
3275
-            (Key::Char('d'), Modifiers { ctrl: false, alt: false, .. }) => {
3311
+            (Key::Char('d'), _) => {
32763312
                 if let Some((filename, diff)) = self.workspace.fuss.get_diff_for_selected() {
32773313
                     let display_name = format!("[diff] {}", filename);
32783314
                     self.workspace.open_content_tab(&diff, &display_name);
@@ -3283,7 +3319,7 @@ impl Editor {
32833319
             }
32843320
 
32853321
             // Git: Commit (m) - opens prompt for commit message
3286
-            (Key::Char('m'), Modifiers { ctrl: false, alt: false, .. }) => {
3322
+            (Key::Char('m'), _) => {
32873323
                 self.prompt = PromptState::TextInput {
32883324
                     label: "Commit message: ".to_string(),
32893325
                     buffer: String::new(),
@@ -3293,25 +3329,25 @@ impl Editor {
32933329
             }
32943330
 
32953331
             // Git: Push (p)
3296
-            (Key::Char('p'), Modifiers { ctrl: false, alt: false, .. }) => {
3332
+            (Key::Char('p'), _) => {
32973333
                 let (_, msg) = self.workspace.fuss.git_push();
32983334
                 self.message = Some(msg);
32993335
             }
33003336
 
33013337
             // Git: Pull (l)
3302
-            (Key::Char('l'), Modifiers { ctrl: false, alt: false, .. }) => {
3338
+            (Key::Char('l'), _) => {
33033339
                 let (_, msg) = self.workspace.fuss.git_pull();
33043340
                 self.message = Some(msg);
33053341
             }
33063342
 
33073343
             // Git: Fetch (f)
3308
-            (Key::Char('f'), Modifiers { ctrl: false, alt: false, .. }) => {
3344
+            (Key::Char('f'), _) => {
33093345
                 let (_, msg) = self.workspace.fuss.git_fetch();
33103346
                 self.message = Some(msg);
33113347
             }
33123348
 
33133349
             // Git: Tag (t) - opens prompt for tag name
3314
-            (Key::Char('t'), Modifiers { ctrl: false, alt: false, .. }) => {
3350
+            (Key::Char('t'), _) => {
33153351
                 self.prompt = PromptState::TextInput {
33163352
                     label: "Tag name: ".to_string(),
33173353
                     buffer: String::new(),
@@ -3320,6 +3356,7 @@ impl Editor {
33203356
                 self.message = Some("Enter tag name (Enter to create, Esc to cancel)".to_string());
33213357
             }
33223358
 
3359
+            // Escape or any other key just cancels git mode
33233360
             _ => {}
33243361
         }
33253362
         Ok(())
src/fuss/state.rsmodified
@@ -4,8 +4,12 @@
44
 
55
 use std::path::{Path, PathBuf};
66
 use std::process::Command;
7
+use std::time::Instant;
78
 use super::tree::FileTree;
89
 
10
+/// Timeout for filter reset (in milliseconds)
11
+const FILTER_TIMEOUT_MS: u128 = 500;
12
+
913
 /// Fuss mode state
1014
 #[derive(Debug)]
1115
 pub struct FussMode {
@@ -23,6 +27,12 @@ pub struct FussMode {
2327
     pub hints_expanded: bool,
2428
     /// Workspace root path
2529
     root_path: Option<PathBuf>,
30
+    /// Current fuzzy filter query
31
+    pub filter: String,
32
+    /// Last time a filter character was typed
33
+    filter_last_input: Option<Instant>,
34
+    /// Whether git mode is active (after pressing Alt+G)
35
+    pub git_mode: bool,
2636
 }
2737
 
2838
 impl Default for FussMode {
@@ -35,6 +45,9 @@ impl Default for FussMode {
3545
             width_percent: 30,
3646
             hints_expanded: false,
3747
             root_path: None,
48
+            filter: String::new(),
49
+            filter_last_input: None,
50
+            git_mode: false,
3851
         }
3952
     }
4053
 }
@@ -476,4 +489,91 @@ impl FussMode {
476489
             None
477490
         }
478491
     }
492
+
493
+    /// Add a character to the filter and jump to first match
494
+    /// Resets the filter if too much time has passed since last input
495
+    pub fn filter_push(&mut self, c: char) {
496
+        let now = Instant::now();
497
+
498
+        // Check if we should reset the filter due to timeout
499
+        if let Some(last) = self.filter_last_input {
500
+            if now.duration_since(last).as_millis() > FILTER_TIMEOUT_MS {
501
+                self.filter.clear();
502
+            }
503
+        }
504
+
505
+        self.filter.push(c);
506
+        self.filter_last_input = Some(now);
507
+        self.jump_to_filter_match();
508
+    }
509
+
510
+    /// Remove last character from filter
511
+    pub fn filter_pop(&mut self) {
512
+        self.filter.pop();
513
+        if !self.filter.is_empty() {
514
+            self.jump_to_filter_match();
515
+        }
516
+    }
517
+
518
+    /// Clear the filter
519
+    pub fn filter_clear(&mut self) {
520
+        self.filter.clear();
521
+        self.filter_last_input = None;
522
+    }
523
+
524
+    /// Jump to the first item matching the current filter (fuzzy match)
525
+    fn jump_to_filter_match(&mut self) {
526
+        if self.filter.is_empty() {
527
+            return;
528
+        }
529
+
530
+        let tree = match &self.tree {
531
+            Some(t) => t,
532
+            None => return,
533
+        };
534
+
535
+        let items = tree.visible_items();
536
+        let query = self.filter.to_lowercase();
537
+
538
+        // Find best matching item starting from current position + 1
539
+        // This allows pressing the same keys repeatedly to cycle through matches
540
+        let start = (self.selected + 1) % items.len().max(1);
541
+
542
+        // First try: find match starting from current position
543
+        for offset in 0..items.len() {
544
+            let idx = (start + offset) % items.len();
545
+            let name = items[idx].name.to_lowercase();
546
+
547
+            if fuzzy_match(&name, &query) {
548
+                self.selected = idx;
549
+                return;
550
+            }
551
+        }
552
+    }
553
+
554
+    /// Enter git mode (after Alt+G)
555
+    pub fn enter_git_mode(&mut self) {
556
+        self.git_mode = true;
557
+    }
558
+
559
+    /// Exit git mode
560
+    pub fn exit_git_mode(&mut self) {
561
+        self.git_mode = false;
562
+    }
563
+}
564
+
565
+/// Simple fuzzy matching: checks if query characters appear in order in the target
566
+fn fuzzy_match(target: &str, query: &str) -> bool {
567
+    let mut query_chars = query.chars().peekable();
568
+
569
+    for c in target.chars() {
570
+        if query_chars.peek() == Some(&c) {
571
+            query_chars.next();
572
+        }
573
+        if query_chars.peek().is_none() {
574
+            return true;
575
+        }
576
+    }
577
+
578
+    query_chars.peek().is_none()
479579
 }
src/render/screen.rsmodified
@@ -841,11 +841,13 @@ impl Screen {
841841
         hints_expanded: bool,
842842
         repo_name: &str,
843843
         branch: Option<&str>,
844
+        git_mode: bool,
844845
     ) -> Result<()> {
845846
         let width = width as usize;
846847
         let text_rows = self.rows.saturating_sub(1) as usize;
847848
         let hint_rows = if hints_expanded { 4 } else { 1 };
848
-        let header_rows = 2; // Header line + separator
849
+        // Header line + separator + optional git mode line
850
+        let header_rows = if git_mode { 3 } else { 2 };
849851
         let tree_rows = text_rows.saturating_sub(hint_rows + header_rows);
850852
 
851853
         // Draw header: repo_name:branch
@@ -893,6 +895,21 @@ impl Screen {
893895
             ResetColor,
894896
         )?;
895897
 
898
+        // Draw git mode indicator line
899
+        if git_mode {
900
+            let git_row = 2u16;
901
+            execute!(self.stdout, MoveTo(0, git_row))?;
902
+            let git_hint = "Git: a/u/d/m/p/l/f/t";
903
+            let padded = format!("{:<width$}", git_hint, width = width);
904
+            execute!(
905
+                self.stdout,
906
+                SetBackgroundColor(Color::AnsiValue(235)),
907
+                SetForegroundColor(Color::Yellow),
908
+                Print(&padded),
909
+                ResetColor,
910
+            )?;
911
+        }
912
+
896913
         // Draw file tree (starting after header)
897914
         for row in 0..tree_rows {
898915
             let screen_row = (row + header_rows) as u16;
@@ -1015,10 +1032,10 @@ impl Screen {
10151032
         let hint_start = header_rows + tree_rows;
10161033
         if hints_expanded {
10171034
             let hints = [
1018
-                "j/k:nav spc:toggle o:open .:hidden",
1019
-                "a:stage u:unstage d:diff m:commit",
1020
-                "p:push l:pull f:fetch t:tag",
1021
-                "ctrl-b:close ctrl-/:hints",
1035
+                "type:jump  spc:toggle  enter:open",
1036
+                "alt-.:hidden  alt-g:git  ctrl-v/s:split",
1037
+                "ctrl-b:close  ctrl-/:hints",
1038
+                "",
10221039
             ];
10231040
             for (i, hint) in hints.iter().enumerate() {
10241041
                 if hint_start + i < text_rows {
@@ -1994,6 +2011,7 @@ impl Screen {
19942011
             Print(&title),
19952012
             SetForegroundColor(border_color),
19962013
             Print(format!("{:─<width$}┐", "", width = panel_width.saturating_sub(title.len() + 2))),
2014
+            ResetColor,
19972015
         )?;
19982016
 
19992017
         // Draw filter input row
@@ -2011,6 +2029,7 @@ impl Screen {
20112029
             SetBackgroundColor(bg),
20122030
             SetForegroundColor(border_color),
20132031
             Print("│"),
2032
+            ResetColor,
20142033
         )?;
20152034
 
20162035
         // Draw separator
@@ -2020,6 +2039,7 @@ impl Screen {
20202039
             SetBackgroundColor(bg),
20212040
             SetForegroundColor(border_color),
20222041
             Print(format!("├{:─<width$}┤", "", width = panel_width.saturating_sub(2))),
2042
+            ResetColor,
20232043
         )?;
20242044
 
20252045
         // Calculate visible range with scrolling
@@ -2063,6 +2083,12 @@ impl Screen {
20632083
 
20642084
             let item_bg = if is_selected { selected_bg } else { bg };
20652085
 
2086
+            // Build a fixed-width line: "│ " + path (padded to max_path_width) + line_info + " │"
2087
+            // Total: 2 + max_path_width + line_info.len() + 2 = panel_width
2088
+            // So we need: max_path_width = panel_width - line_info.len() - 4
2089
+            // The remaining padding goes after line_info
2090
+            let remaining = panel_width.saturating_sub(max_path_width + line_info.len() + 4);
2091
+
20662092
             execute!(
20672093
                 self.stdout,
20682094
                 MoveTo(start_col as u16, row),
@@ -2073,8 +2099,10 @@ impl Screen {
20732099
                 Print(format!("{:<width$}", truncated_path, width = max_path_width)),
20742100
                 SetForegroundColor(line_num_color),
20752101
                 Print(&line_info),
2102
+                Print(format!("{:width$}", "", width = remaining)),
20762103
                 SetForegroundColor(border_color),
2077
-                Print(format!("{:>width$}│", "", width = panel_width.saturating_sub(truncated_path.len() + line_info.len() + 3))),
2104
+                Print(" │"),
2105
+                ResetColor,
20782106
             )?;
20792107
         }
20802108
 
@@ -2088,6 +2116,7 @@ impl Screen {
20882116
                 SetBackgroundColor(bg),
20892117
                 SetForegroundColor(border_color),
20902118
                 Print(format!("│{:width$}│", "", width = panel_width.saturating_sub(2))),
2119
+                ResetColor,
20912120
             )?;
20922121
         }
20932122
 
@@ -2104,6 +2133,7 @@ impl Screen {
21042133
             Print(format!(" {:<width$}", help_text, width = panel_width.saturating_sub(3))),
21052134
             SetForegroundColor(border_color),
21062135
             Print("┤"),
2136
+            ResetColor,
21072137
         )?;
21082138
 
21092139
         // Draw bottom border
@@ -2119,6 +2149,7 @@ impl Screen {
21192149
         // Hide cursor when in references panel
21202150
         execute!(self.stdout, Hide)?;
21212151
 
2152
+        self.stdout.flush()?;
21222153
         Ok(())
21232154
     }
21242155