refactor(tui/ui): extract file-picker relevance scoring
The `/files` picker ranks workspace files by three signals harvested from the session: which files git reports as modified, which the user @-mentioned in the composer, and which recent tool calls touched. The scoring code — `open_file_picker` plus 9 helpers (build_relevance, modified_workspace_paths, parse_git_status_path, mark_tool_detail_paths / from_value / from_text, workspace_file_candidate, clean_path_token, workspace_path_to_picker_string) — was ~218 lines of self-contained logic mid-ui.rs. Moved to `tui/file_picker_relevance.rs`. Same behavior; the picker view file (`tui/file_picker.rs`) keeps the rendering layer, and the new module owns the per-session ranking that fed it. ui.rs is now 9073 lines (down ~950 from the pre-refactor 10025).
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
//! Helpers that decide which workspace files to surface in the
|
||||
//! `/files` picker.
|
||||
//!
|
||||
//! The picker ranks files by three signals harvested from the running
|
||||
//! session:
|
||||
//!
|
||||
//! * `modified` — files git reports as staged/unstaged or untracked
|
||||
//! * `mentioned` — files the user @-referenced in the composer
|
||||
//! * `tool` — files that recent tool calls touched (input or output)
|
||||
//!
|
||||
//! [`build_relevance`] composes those signals into a
|
||||
//! `FilePickerRelevance` that the picker view uses to order results.
|
||||
//! The remaining helpers are deterministic string/path utilities that
|
||||
//! make path discovery resilient to quoting, leading `./`, and
|
||||
//! trailing `:line` markers.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::file_picker::FilePickerRelevance;
|
||||
use crate::tui::file_picker::FilePickerView;
|
||||
use crate::tui::file_mention::{ContextReferenceKind, ContextReferenceSource};
|
||||
use crate::tui::app::ToolDetailRecord;
|
||||
|
||||
/// Push the `/files` picker onto the view stack, pre-populated with
|
||||
/// per-session relevance ranks (modified, @-mentioned, tool-touched).
|
||||
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) }
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub mod feedback_picker;
|
||||
pub mod file_frecency;
|
||||
pub mod file_mention;
|
||||
pub mod file_picker;
|
||||
pub mod file_picker_relevance;
|
||||
pub mod file_tree;
|
||||
pub mod frame_rate_limiter;
|
||||
pub mod history;
|
||||
|
||||
+5
-222
@@ -1,8 +1,7 @@
|
||||
//! TUI event loop and rendering logic for `DeepSeek` CLI.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{self, Stdout, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -74,6 +73,7 @@ use crate::tui::vim_mode;
|
||||
use crate::tui::streaming_thinking;
|
||||
use crate::tui::workspace_context;
|
||||
use crate::tui::notifications;
|
||||
use crate::tui::file_picker_relevance;
|
||||
use crate::tui::onboarding;
|
||||
use crate::tui::pager::PagerView;
|
||||
use crate::tui::persistence_actor::{self, PersistRequest};
|
||||
@@ -100,7 +100,7 @@ use crate::tui::views::subagent_view_agents;
|
||||
|
||||
use super::app::{
|
||||
App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus,
|
||||
StatusToastLevel, SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions,
|
||||
StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions,
|
||||
};
|
||||
use super::approval::{
|
||||
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
|
||||
@@ -2376,7 +2376,7 @@ async fn run_event_loop(
|
||||
&& app.view_stack.is_empty()
|
||||
&& !app.is_loading
|
||||
{
|
||||
open_file_picker(app);
|
||||
file_picker_relevance::open_file_picker(app);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4396,224 +4396,7 @@ fn open_context_inspector(app: &mut App) {
|
||||
));
|
||||
}
|
||||
|
||||
fn open_file_picker(app: &mut App) {
|
||||
let relevance = build_file_picker_relevance(app);
|
||||
app.view_stack
|
||||
.push(crate::tui::file_picker::FilePickerView::new_with_relevance(
|
||||
&app.workspace,
|
||||
relevance,
|
||||
));
|
||||
}
|
||||
|
||||
fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance {
|
||||
let mut relevance = crate::tui::file_picker::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 != crate::tui::file_mention::ContextReferenceSource::AtMention {
|
||||
continue;
|
||||
}
|
||||
if !matches!(
|
||||
reference.kind,
|
||||
crate::tui::file_mention::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()
|
||||
}
|
||||
|
||||
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 crate::tui::file_picker::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 crate::tui::file_picker::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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_tool_paths_from_text(
|
||||
text: &str,
|
||||
workspace: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
relevance: &mut crate::tui::file_picker::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
// File-picker relevance scoring moved to `tui/file_picker_relevance.rs`.
|
||||
|
||||
async fn apply_command_result(
|
||||
terminal: &mut AppTerminal,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
use crate::tui::app::ToolDetailRecord;
|
||||
use std::collections::HashSet;
|
||||
use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};
|
||||
use crate::core::engine::mock_engine_handle;
|
||||
use crate::tui::file_mention::{
|
||||
@@ -1055,11 +1057,11 @@ fn transcript_scroll_percent_is_clamped_and_relative() {
|
||||
#[test]
|
||||
fn parse_git_status_path_handles_simple_and_renamed_entries() {
|
||||
assert_eq!(
|
||||
parse_git_status_path(" M crates/tui/src/tui/ui.rs"),
|
||||
crate::tui::file_picker_relevance::parse_git_status_path(" M crates/tui/src/tui/ui.rs"),
|
||||
Some("crates/tui/src/tui/ui.rs".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_git_status_path("R old name.rs -> crates/tui/src/tui/file_picker.rs"),
|
||||
crate::tui::file_picker_relevance::parse_git_status_path("R old name.rs -> crates/tui/src/tui/file_picker.rs"),
|
||||
Some("crates/tui/src/tui/file_picker.rs".to_string())
|
||||
);
|
||||
}
|
||||
@@ -1074,7 +1076,7 @@ fn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {
|
||||
|
||||
let raw = format!("\"{}:42\",", path.display());
|
||||
assert_eq!(
|
||||
workspace_file_candidate(&raw, root),
|
||||
crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),
|
||||
Some("src/lib.rs".to_string())
|
||||
);
|
||||
}
|
||||
@@ -1090,7 +1092,7 @@ fn tool_path_relevance_extracts_paths_from_command_text() {
|
||||
let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut budget = 16;
|
||||
mark_tool_paths_from_text(
|
||||
crate::tui::file_picker_relevance::mark_tool_paths_from_text(
|
||||
"sed -n '1,20p' src/zeta.rs",
|
||||
root,
|
||||
&mut seen,
|
||||
|
||||
Reference in New Issue
Block a user