1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
//! `Pane::Grep` — workspace-grep results as a browsable list. The grep itself
//! is shelled out by `App::run_workspace_grep` (`rg --vimgrep`, falling back to
//! `git grep -n --column`); the parser + the pane state live here so they're
//! cheap to unit-test. `↑↓`/`jk` select, `Enter` jumps to the hit's file +
//! line, `r` re-runs the query, `Esc` → tree. Unlike the `Locations` picker
//! (which closes on Enter), the pane stays open — "jump and keep the list".
use std::path::{Path, PathBuf};
/// One match the workspace-grep tool produced.
#[derive(Debug, Clone)]
pub struct GrepHit {
pub path: PathBuf,
/// Workspace-relative path (what the grep tool emitted).
pub rel: String,
/// 0-based line/column of the match.
pub line: u32,
pub col: u32,
/// The matched line, trim-left-trimmed for display.
pub text: String,
}
pub struct GrepPane {
pub query: String,
/// Which tool produced these — drives the title and the `r` re-run.
pub used: &'static str,
pub hits: Vec<GrepHit>,
pub selected: usize,
pub scroll: usize,
/// Hit indices the user has toggled off — they're skipped when
/// `R` (replace) fires. Empty set ⇒ replace applies to every hit
/// (back-compat with the original behavior). Integration to Space-to-
/// toggle in the pane key handler.
pub disabled: std::collections::HashSet<usize>,
}
impl GrepPane {
pub fn new(query: String, used: &'static str, hits: Vec<GrepHit>) -> Self {
GrepPane {
query,
used,
hits,
selected: 0,
scroll: 0,
disabled: std::collections::HashSet::new(),
}
}
/// Flip the enabled/disabled state of the currently-selected hit.
/// Used by Space in the pane and by checkbox clicks.
pub fn toggle_selected(&mut self) {
if self.disabled.contains(&self.selected) {
self.disabled.remove(&self.selected);
} else {
self.disabled.insert(self.selected);
}
}
/// Re-enable every hit.
pub fn enable_all(&mut self) {
self.disabled.clear();
}
/// Disable every hit (so the user can re-enable just the ones they want).
pub fn disable_all(&mut self) {
self.disabled = (0..self.hits.len()).collect();
}
/// Number of hits the user has marked active (enabled). When `disabled`
/// is empty this equals `hits.len()`.
pub fn enabled_count(&self) -> usize {
self.hits.len().saturating_sub(self.disabled.len())
}
pub fn tab_title(&self) -> String {
let n = self.hits.len();
let q = if self.query.chars().count() > 24 {
let truncated: String = self.query.chars().take(24).collect();
format!("{truncated}…")
} else {
self.query.clone()
};
format!("grep:{q} ({n})")
}
/// Keep `selected` in range after a rebuild.
pub fn clamp(&mut self) {
if self.hits.is_empty() {
self.selected = 0;
} else if self.selected >= self.hits.len() {
self.selected = self.hits.len() - 1;
}
}
pub fn move_selection(&mut self, delta: isize) {
if self.hits.is_empty() {
return;
}
let n = self.hits.len() as isize;
self.selected = (self.selected as isize + delta).clamp(0, n - 1) as usize;
}
pub fn selected_hit(&self) -> Option<&GrepHit> {
self.hits.get(self.selected)
}
}
/// Parse `rg --vimgrep` / `git grep -n --column` output (both share the
/// `path:line:col:text` shape) into `GrepHit`s, resolved against `workspace`.
/// 1-based on the wire; the hits store 0-based line/col so the editor's
/// `place_cursor(row, col)` accepts them directly. Lines that don't parse are
/// skipped; output is capped at 2000 hits.
pub fn parse_rg_vimgrep(stdout: &str, workspace: &Path) -> Vec<GrepHit> {
let mut out = Vec::new();
for line in stdout.lines().take(2000) {
let mut it = line.splitn(4, ':');
let (Some(rel), Some(ln), Some(col), Some(text)) =
(it.next(), it.next(), it.next(), it.next())
else {
continue;
};
let (Ok(ln), Ok(col)) = (ln.parse::<u32>(), col.parse::<u32>()) else {
continue;
};
out.push(GrepHit {
path: workspace.join(rel),
rel: rel.to_string(),
line: ln.saturating_sub(1),
col: col.saturating_sub(1),
text: text.trim_start().to_string(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_vimgrep_lines() {
let ws = Path::new("/ws");
let out = parse_rg_vimgrep(
"src/app.rs:42:5: let x = 1;\nsrc/lib.rs:10:1:fn foo() {}\n",
ws,
);
assert_eq!(out.len(), 2);
assert_eq!(out[0].rel, "src/app.rs");
assert_eq!(out[0].path, ws.join("src/app.rs"));
assert_eq!(out[0].line, 41);
assert_eq!(out[0].col, 4);
assert_eq!(out[0].text, "let x = 1;");
assert_eq!(out[1].rel, "src/lib.rs");
assert_eq!(out[1].line, 9);
assert_eq!(out[1].col, 0);
}
#[test]
fn text_may_contain_colons() {
// The `text` chunk is whatever's left after the first three `:`s.
let out = parse_rg_vimgrep("a.rs:1:1:url: https://x.com:8080/p\n", Path::new("/ws"));
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, "url: https://x.com:8080/p");
}
#[test]
fn malformed_lines_skipped() {
let out = parse_rg_vimgrep("not vimgrep\nfoo:nan:1:t\nsrc:5:7:ok\n", Path::new("/ws"));
assert_eq!(out.len(), 1);
assert_eq!(out[0].rel, "src");
assert_eq!(out[0].text, "ok");
}
#[test]
fn move_selection_clamps() {
let mut p = GrepPane::new(
"x".into(),
"rg",
vec![
GrepHit {
path: PathBuf::from("/a"),
rel: "a".into(),
line: 0,
col: 0,
text: "a".into(),
},
GrepHit {
path: PathBuf::from("/b"),
rel: "b".into(),
line: 0,
col: 0,
text: "b".into(),
},
],
);
p.move_selection(-5);
assert_eq!(p.selected, 0);
p.move_selection(99);
assert_eq!(p.selected, 1);
}
#[test]
fn tab_title_includes_count_and_truncates_query() {
let p = GrepPane::new("hello".into(), "rg", Vec::new());
assert_eq!(p.tab_title(), "grep:hello (0)");
let long = "x".repeat(40);
let p = GrepPane::new(long, "rg", Vec::new());
// 24 chars + `…` (the truncation marker) + " (0)"
assert!(p.tab_title().contains('…'));
}
}