返回 CodeWhale
file_picker_relevance.rs
根目录 / crates / tui / src / tui / file_picker_relevance.rs
1 //! Helpers that decide which workspace files to surface in the
2 //! `/files` picker.
3 //!
4 //! The picker ranks files by three signals harvested from the running
5 //! session:
6 //!
7 //! * `modified` — files git reports as staged/unstaged or untracked
8 //! * `mentioned` — files the user @-referenced in the composer
9 //! * `tool` — files that recent tool calls touched (input or output)
10 //!
11 //! [`build_relevance`] composes those signals into a
12 //! `FilePickerRelevance` that the picker view uses to order results.
13 //! The remaining helpers are deterministic string/path utilities that
14 //! make path discovery resilient to quoting, leading `./`, and
15 //! trailing `:line` markers.
16
17 use crate::dependencies::{ExternalTool, Git};
18 use std::collections::HashSet;
19 use std::path::{Path, PathBuf};
20
21 use crate::tui::app::App;
22 use crate::tui::app::ToolDetailRecord;
23 use crate::tui::file_mention::{ContextReferenceKind, ContextReferenceSource};
24 use crate::tui::file_picker::FilePickerRelevance;
25 use crate::tui::file_picker::FilePickerView;
26
27 /// Push the `/files` picker onto the view stack, pre-populated with
28 /// per-session relevance ranks (modified, @-mentioned, tool-touched).
29 pub(super) fn open_file_picker(app: &mut App) {
30 let relevance = build_relevance(app);
31 // Honor the configured `mention_walk_depth` (0 = unlimited) so the picker
32 // and `@`-mention completion agree, and files in deeply nested trees stay
33 // discoverable (#2488).
34 app.view_stack
35 .push(FilePickerView::new_with_relevance_and_depth(
36 &app.workspace,
37 relevance,
38 app.mention_walk_depth,
39 app.ui_locale,
40 ));
41 }
42
43 /// Compose the in-memory relevance signals (@-mentions, tool-touched paths).
44 ///
45 /// The git-reported `modified` signal is deliberately *not* gathered here: it
46 /// costs a subprocess, so the picker folds it in from its background scan
47 /// (#3905).
48 pub(super) fn build_relevance(app: &App) -> FilePickerRelevance {
49 let mut relevance = FilePickerRelevance::default();
50
51 for record in app.session_context_references.iter().rev().take(64) {
52 let reference = &record.reference;
53 if reference.source != ContextReferenceSource::AtMention {
54 continue;
55 }
56 if !matches!(reference.kind, ContextReferenceKind::File) {
57 continue;
58 }
59 for raw in [&reference.target, &reference.label] {
60 if let Some(path) = workspace_file_candidate(raw, &app.workspace) {
61 relevance.mark_mentioned(path);
62 }
63 }
64 }
65
66 let mut seen_tool_paths = HashSet::new();
67 for detail in app.active_tool_details.values() {
68 mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
69 }
70 let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect();
71 rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx));
72 for (_, detail) in rows.into_iter().take(48) {
73 mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
74 }
75
76 relevance
77 }
78
79 /// Paths git reports as staged/unstaged/untracked.
80 ///
81 /// Blocking: spawns `git status` and waits. The picker runs this on a blocking
82 /// task rather than the event loop (#3905), so it lives here but is called
83 /// from `file_picker.rs`.
84 pub(super) fn modified_workspace_paths(workspace: &Path) -> Vec<String> {
85 let Some(mut cmd) = Git::command() else {
86 return Vec::new();
87 };
88 let Ok(output) = cmd
89 .arg("-C")
90 .arg(workspace)
91 .args(["status", "--short", "--untracked-files=normal"])
92 .output()
93 else {
94 return Vec::new();
95 };
96 if !output.status.success() {
97 return Vec::new();
98 }
99
100 String::from_utf8_lossy(&output.stdout)
101 .lines()
102 .filter_map(parse_git_status_path)
103 .filter_map(|path| workspace_file_candidate(&path, workspace))
104 .collect()
105 }
106
107 pub(super) fn parse_git_status_path(line: &str) -> Option<String> {
108 if line.len() < 4 {
109 return None;
110 }
111 let raw = line.get(3..)?.trim();
112 let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim();
113 let raw = raw.trim_matches('"');
114 if raw.is_empty() {
115 None
116 } else {
117 Some(raw.to_string())
118 }
119 }
120
121 fn mark_tool_detail_paths(
122 detail: &ToolDetailRecord,
123 workspace: &Path,
124 seen: &mut HashSet<String>,
125 relevance: &mut FilePickerRelevance,
126 ) {
127 let mut budget = 256usize;
128 mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget);
129 if let Some(output) = detail
130 .output
131 .as_deref()
132 .filter(|output| output.len() <= 8_192)
133 {
134 mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget);
135 }
136 }
137
138 fn mark_tool_paths_from_value(
139 value: &serde_json::Value,
140 workspace: &Path,
141 seen: &mut HashSet<String>,
142 relevance: &mut FilePickerRelevance,
143 budget: &mut usize,
144 ) {
145 if *budget == 0 {
146 return;
147 }
148 match value {
149 serde_json::Value::String(text) => {
150 mark_tool_paths_from_text(text, workspace, seen, relevance, budget);
151 }
152 serde_json::Value::Array(items) => {
153 for item in items {
154 mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
155 if *budget == 0 {
156 break;
157 }
158 }
159 }
160 serde_json::Value::Object(map) => {
161 for item in map.values() {
162 mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
163 if *budget == 0 {
164 break;
165 }
166 }
167 }
168 _ => {}
169 }
170 }
171
172 pub(super) fn mark_tool_paths_from_text(
173 text: &str,
174 workspace: &Path,
175 seen: &mut HashSet<String>,
176 relevance: &mut FilePickerRelevance,
177 budget: &mut usize,
178 ) {
179 if *budget == 0 || text.len() > 8_192 {
180 return;
181 }
182 if let Some(path) = workspace_file_candidate(text, workspace)
183 && seen.insert(path.clone())
184 {
185 relevance.mark_tool(path);
186 *budget = (*budget).saturating_sub(1);
187 }
188 for token in text.split_whitespace().take(128) {
189 if *budget == 0 {
190 break;
191 }
192 if let Some(path) = workspace_file_candidate(token, workspace)
193 && seen.insert(path.clone())
194 {
195 relevance.mark_tool(path);
196 *budget = (*budget).saturating_sub(1);
197 }
198 }
199 }
200
201 pub(super) fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option<String> {
202 let cleaned = clean_path_token(raw)?;
203 let path = Path::new(&cleaned);
204 let absolute = if path.is_absolute() {
205 PathBuf::from(path)
206 } else {
207 workspace.join(path)
208 };
209 if !absolute.is_file() {
210 return None;
211 }
212 let rel = absolute.strip_prefix(workspace).ok()?;
213 workspace_path_to_picker_string(rel)
214 }
215
216 fn clean_path_token(raw: &str) -> Option<String> {
217 let mut trimmed = raw.trim().trim_matches(|ch: char| {
218 ch.is_ascii_whitespace()
219 || matches!(
220 ch,
221 '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
222 )
223 });
224 if let Some(stripped) = trimmed.strip_prefix("./") {
225 trimmed = stripped;
226 }
227 if let Some((before, after)) = trimmed.rsplit_once(':')
228 && !before.is_empty()
229 && after.chars().all(|ch| ch.is_ascii_digit())
230 {
231 trimmed = before;
232 }
233 if trimmed.is_empty() {
234 None
235 } else {
236 Some(trimmed.to_string())
237 }
238 }
239
240 fn workspace_path_to_picker_string(path: &Path) -> Option<String> {
241 let mut out = String::new();
242 for (idx, component) in path.components().enumerate() {
243 if matches!(
244 component,
245 std::path::Component::ParentDir
246 | std::path::Component::RootDir
247 | std::path::Component::Prefix(_)
248 ) {
249 return None;
250 }
251 if idx > 0 {
252 out.push('/');
253 }
254 out.push_str(&component.as_os_str().to_string_lossy());
255 }
256 if out.is_empty() { None } else { Some(out) }
257 }
258
258 lines RUST