| 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 |