use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::tui::app::App;
use crate::tui::app::ToolDetailRecord;
use crate::tui::file_mention::{ContextReferenceKind, ContextReferenceSource};
use crate::tui::file_picker::FilePickerRelevance;
use crate::tui::file_picker::FilePickerView;
pub(super) fn open_file_picker(app: &mut App) {
let relevance = build_relevance(app);
app.view_stack.push(FilePickerView::new_with_relevance(
&app.workspace,
relevance,
));
}
pub(super) fn build_relevance(app: &App) -> FilePickerRelevance {
let mut relevance = FilePickerRelevance::default();
for path in modified_workspace_paths(&app.workspace) {
relevance.mark_modified(path);
}
for record in app.session_context_references.iter().rev().take(64) {
let reference = &record.reference;
if reference.source != ContextReferenceSource::AtMention {
continue;
}
if !matches!(reference.kind, ContextReferenceKind::File) {
continue;
}
for raw in [&reference.target, &reference.label] {
if let Some(path) = workspace_file_candidate(raw, &app.workspace) {
relevance.mark_mentioned(path);
}
}
}
let mut seen_tool_paths = HashSet::new();
for detail in app.active_tool_details.values() {
mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
}
let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect();
rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx));
for (_, detail) in rows.into_iter().take(48) {
mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
}
relevance
}
fn modified_workspace_paths(workspace: &Path) -> Vec<String> {
let Ok(output) = Command::new("git")
.arg("-C")
.arg(workspace)
.args(["status", "--short", "--untracked-files=normal"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(parse_git_status_path)
.filter_map(|path| workspace_file_candidate(&path, workspace))
.collect()
}
pub(super) fn parse_git_status_path(line: &str) -> Option<String> {
if line.len() < 4 {
return None;
}
let raw = line.get(3..)?.trim();
let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim();
let raw = raw.trim_matches('"');
if raw.is_empty() {
None
} else {
Some(raw.to_string())
}
}
fn mark_tool_detail_paths(
detail: &ToolDetailRecord,
workspace: &Path,
seen: &mut HashSet<String>,
relevance: &mut FilePickerRelevance,
) {
let mut budget = 256usize;
mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget);
if let Some(output) = detail
.output
.as_deref()
.filter(|output| output.len() <= 8_192)
{
mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget);
}
}
fn mark_tool_paths_from_value(
value: &serde_json::Value,
workspace: &Path,
seen: &mut HashSet<String>,
relevance: &mut FilePickerRelevance,
budget: &mut usize,
) {
if *budget == 0 {
return;
}
match value {
serde_json::Value::String(text) => {
mark_tool_paths_from_text(text, workspace, seen, relevance, budget);
}
serde_json::Value::Array(items) => {
for item in items {
mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
if *budget == 0 {
break;
}
}
}
serde_json::Value::Object(map) => {
for item in map.values() {
mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
if *budget == 0 {
break;
}
}
}
_ => {}
}
}
pub(super) fn mark_tool_paths_from_text(
text: &str,
workspace: &Path,
seen: &mut HashSet<String>,
relevance: &mut FilePickerRelevance,
budget: &mut usize,
) {
if *budget == 0 || text.len() > 8_192 {
return;
}
if let Some(path) = workspace_file_candidate(text, workspace)
&& seen.insert(path.clone())
{
relevance.mark_tool(path);
*budget = (*budget).saturating_sub(1);
}
for token in text.split_whitespace().take(128) {
if *budget == 0 {
break;
}
if let Some(path) = workspace_file_candidate(token, workspace)
&& seen.insert(path.clone())
{
relevance.mark_tool(path);
*budget = (*budget).saturating_sub(1);
}
}
}
pub(super) fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option<String> {
let cleaned = clean_path_token(raw)?;
let path = Path::new(&cleaned);
let absolute = if path.is_absolute() {
PathBuf::from(path)
} else {
workspace.join(path)
};
if !absolute.is_file() {
return None;
}
let rel = absolute.strip_prefix(workspace).ok()?;
workspace_path_to_picker_string(rel)
}
fn clean_path_token(raw: &str) -> Option<String> {
let mut trimmed = raw.trim().trim_matches(|ch: char| {
ch.is_ascii_whitespace()
|| matches!(
ch,
'"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
)
});
if let Some(stripped) = trimmed.strip_prefix("./") {
trimmed = stripped;
}
if let Some((before, after)) = trimmed.rsplit_once(':')
&& !before.is_empty()
&& after.chars().all(|ch| ch.is_ascii_digit())
{
trimmed = before;
}
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn workspace_path_to_picker_string(path: &Path) -> Option<String> {
let mut out = String::new();
for (idx, component) in path.components().enumerate() {
if matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
) {
return None;
}
if idx > 0 {
out.push('/');
}
out.push_str(&component.as_os_str().to_string_lossy());
}
if out.is_empty() { None } else { Some(out) }
}