优化: 文件预览大纲提取 + 预览/图标/滚动条打磨

This commit is contained in:
lxy
2026-08-10 02:23:53 +08:00
parent 40224d603a
commit 2473dcfe68
10 changed files with 905 additions and 58 deletions
+207 -5
View File
@@ -83,11 +83,7 @@ pub fn grammar_for(ext: &str) -> Option<Language> {
}
}
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。
///
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
#[allow(dead_code)]
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。file_outline 据此提前判 ext(不支持不读文件)。
pub fn is_supported_ext(ext: &str) -> bool {
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
}
@@ -407,6 +403,118 @@ fn collect_calls_inner(
}
}
/// 文件符号概览(outline)— 提取**顶层**定义符号列表,供前端预览面板做符号树。
///
/// 与 read_symbol 复用同一套解析(grammar_for + DEFINITION_KINDS + Vue 切 script 段),
/// 但只返精简元数据 `{ name, kind, line, signature }`(不含全文/调用,轻量)。
/// 遍历语义:命中 DEFINITION_KINDS 的节点记入并**不下钻其子树** —— impl 内方法、
/// 函数/类内嵌套定义不算顶层(与 IDE 符号树顶层视图一致)。
///
/// **不报错底线**:无 grammar / 解析失败 / 无符号 / Vue 无 script → 空 Vec,不 Err 不 panic。
pub fn extract_outline(source: &str, ext: &str) -> Vec<serde_json::Value> {
let ext_lower = ext.to_lowercase();
// Vue 借 TS:切 script 段,行号偏移映射回原文件(与 read_symbol 同源)。
let (parse_source, parse_ext, line_offset): (String, &str, usize) = if ext_lower == "vue" {
match extract_vue_script(source) {
Some((script, byte_off)) => {
let off_lines = source[..byte_off].matches('\n').count();
(script, "ts", off_lines)
}
None => return Vec::new(), // 纯 template/style 无可提取符号
}
} else {
(source.to_string(), ext_lower.as_str(), 0)
};
let grammar = match grammar_for(parse_ext) {
Some(g) => g,
None => return Vec::new(), // 不支持的语言
};
let mut parser = Parser::new();
if parser.set_language(&grammar).is_err() {
return Vec::new(); // grammar 加载失败(ABI 不兼容)
}
let tree = match parser.parse(&parse_source, None) {
Some(t) => t,
None => return Vec::new(), // 解析失败
};
let bytes = parse_source.as_bytes();
let mut symbols = Vec::new();
collect_top_level_symbols(tree.root_node(), bytes, line_offset, &mut symbols);
symbols
}
/// 收集顶层定义符号(记入 + 不下钻:命中定义节点后不再进其子树)。
fn collect_top_level_symbols(
node: Node<'_>,
source: &[u8],
line_offset: usize,
out: &mut Vec<serde_json::Value>,
) {
if DEFINITION_KINDS.contains(&node.kind()) {
let name = symbol_name(node, source);
let kind = node.kind().to_string();
let line = node.start_position().row + 1 + line_offset;
// Python 等无 `{`/`;` 终结符的语言,extract_signature 会含函数体:outline 只取首行(头行即签名)。
let signature = extract_signature(&node_text(node, source))
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
out.push(serde_json::json!({
"name": name,
"kind": kind,
"line": line,
"signature": signature,
}));
return; // 不下钻(顶层符号语义)
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_top_level_symbols(child, source, line_offset, out);
}
}
/// 取定义节点展示名:name 字段 → type 字段(Rust impl_item)→ 首个 identifier 子节点
/// (Go type_declaration 等 name 藏在一层子节点内)。全空返空串(前端显示签名兜底)。
fn symbol_name(node: Node<'_>, source: &[u8]) -> String {
if let Some(n) = node.child_by_field_name("name") {
let t = node_text(n, source);
if !t.is_empty() {
return t;
}
}
if let Some(n) = node.child_by_field_name("type") {
let t = node_text(n, source);
if !t.is_empty() {
return t;
}
}
find_first_identifier(node, source).unwrap_or_default()
}
/// DFS 找首个 identifier 类子节点(Go type_declaration → type_spec → type_identifier 隔两层)。
fn find_first_identifier(node: Node<'_>, source: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "identifier" || kind.ends_with("_identifier") {
let t = node_text(child, source);
if !t.is_empty() {
return Some(t);
}
}
}
for child in node.children(&mut cursor) {
if let Some(t) = find_first_identifier(child, source) {
return Some(t);
}
}
None
}
/// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。
fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value {
serde_json::json!({
@@ -731,4 +839,98 @@ fn outer() {
let no_script = "<template><div>hi</div></template>\n<style></style>";
assert!(!contains_definition(no_script, "vue"));
}
// ============================================================
// extract_outline — 文件符号概览(顶层符号列表)
// ============================================================
#[test]
fn test_extract_outline_rust_top_level() {
let src = "use std::io;\n\npub fn read_symbol(path: &str) {\n helper(path);\n}\n\nfn helper(_x: &str) {}\n\nstruct Config {\n name: String,\n}\n\nimpl Config {\n fn new() -> Self { Config { name: String::new() } }\n}\n";
let symbols = extract_outline(src, "rs");
// 顶层:read_symbol / helper / Config(struct) / Config(impl);impl 内 new 不下钻
let names: Vec<&str> = symbols.iter().map(|s| s["name"].as_str().unwrap()).collect();
assert_eq!(names, vec!["read_symbol", "helper", "Config", "Config"]);
assert_eq!(symbols[0]["kind"], "function_item");
assert_eq!(symbols[2]["kind"], "struct_item");
// impl Config 的 name 来自 type 字段
assert_eq!(symbols[3]["kind"], "impl_item");
assert_eq!(symbols[3]["name"], "Config");
// 行号 1-based
assert_eq!(symbols[0]["line"].as_u64().unwrap(), 3);
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 7);
// signature 只到首个 {,不含函数体
assert_eq!(symbols[0]["signature"].as_str().unwrap(), "pub fn read_symbol(path: &str)");
assert!(!symbols[0]["signature"].as_str().unwrap().contains("helper(path)"));
}
#[test]
fn test_extract_outline_vue_line_offset() {
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {\n helper();\n}\n\nfunction helper() {}\n</script>\n";
let symbols = extract_outline(vue, "vue");
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0]["name"], "greet");
assert_eq!(symbols[0]["kind"], "function_declaration");
// script 内容以 \n 开头(第 3 行 <script> 标签后),greet 在原文件第 4 行、helper 第 8 行
assert_eq!(symbols[0]["line"].as_u64().unwrap(), 4);
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 8);
}
#[test]
fn test_extract_outline_ts_export_and_arrow() {
let src = "export function greet() { return 1; }\nexport const handler = () => {};\ninterface Foo { bar: string }\n";
let symbols = extract_outline(src, "ts");
let kinds: Vec<&str> = symbols.iter().map(|s| s["kind"].as_str().unwrap()).collect();
// export 包装函数仍命中(export_statement 非定义,下钻到 function_declaration)
assert_eq!(kinds, vec!["function_declaration", "variable_declarator", "interface_declaration"]);
assert_eq!(symbols[0]["name"], "greet");
assert_eq!(symbols[1]["name"], "handler");
assert_eq!(symbols[1]["signature"].as_str().unwrap(), "handler = () =>");
}
#[test]
fn test_extract_outline_go_type_declaration() {
let src = "package main\n\nfunc main() {}\n\ntype User struct {\n Name string\n}\n";
let symbols = extract_outline(src, "go");
assert_eq!(symbols[0]["name"], "main");
assert_eq!(symbols[0]["kind"], "function_declaration");
// Go type_declaration 的 name 藏在一层子节点(type_spec)内,走 identifier 回退
assert_eq!(symbols[1]["name"], "User");
assert_eq!(symbols[1]["kind"], "type_declaration");
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 5);
}
#[test]
fn test_extract_outline_java_class_only_top() {
let src = "public class Foo {\n private int x;\n public void bar() {}\n}\n";
let symbols = extract_outline(src, "java");
// 类内 method 不下钻:仅 class Foo 一个顶层符号
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0]["name"], "Foo");
assert_eq!(symbols[0]["kind"], "class_declaration");
}
#[test]
fn test_extract_outline_python() {
let src = "import os\n\ndef foo(a):\n return a\n\nclass Bar:\n def method(self):\n pass\n";
let symbols = extract_outline(src, "py");
// class 内 method 不下钻
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0]["name"], "foo");
assert_eq!(symbols[0]["kind"], "function_definition");
assert_eq!(symbols[1]["name"], "Bar");
assert_eq!(symbols[1]["kind"], "class_definition");
}
#[test]
fn test_extract_outline_unsupported_or_fail_returns_empty() {
// 不支持语言
assert!(extract_outline("# Title\nplain text", "md").is_empty());
// 无 script 的 Vue
assert!(extract_outline("<template><div>hi</div></template>", "vue").is_empty());
// 纯文本(无定义节点)
assert!(extract_outline("just some text\nno definitions", "rs").is_empty());
// 残缺语法(tree-sitter 容错仍产出节点,不 panic 即可,不强行断言空)
let _ = extract_outline("fn broken(", "rs");
}
}
+78 -1
View File
@@ -25,6 +25,8 @@ use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
use df_types::types::new_id;
// 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段)
use df_project::scan::detect_stack;
// 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取
use crate::commands::ai::code_intel;
use crate::state::AppState;
@@ -889,8 +891,14 @@ pub async fn read_module_file(
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
buf.truncate(n);
// 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。
// 图片扩展名豁免:图片是二进制(含 \0),但前端走 convertFileSrc 本地预览,
// 需 is_binary=false 才能进 isImage 分支(否则显示"二进制不支持预览")。
let is_image = matches!(
abs_clone.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(),
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") | Some("bmp") | Some("svg") | Some("ico") | Some("avif")
);
let check_len = buf.len().min(8192);
let is_binary = buf[..check_len].iter().any(|&b| b == 0);
let is_binary = !is_image && buf[..check_len].iter().any(|&b| b == 0);
Ok((buf, is_binary))
})
.await
@@ -915,6 +923,75 @@ pub async fn read_module_file(
}))
}
/// 提取文件符号概览(outline)—— 顶层定义符号列表,供前端预览面板做符号树。
///
/// 复用 code_intel::extract_outline(grammar_for + DEFINITION_KINDS + Vue 切 script 段)。
/// 只读前部 256KB(符号概览只需头部,超出部分不展示,符合预期)。
/// 不支持的语言(CSS/HTML/MD 等)返 `supported=false` + 空 symbols,前端据此隐藏面板。
#[tauri::command]
pub async fn file_outline(
state: State<'_, AppState>,
module_id: String,
file_path: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
let file_path = file_path.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
if file_path.is_empty() {
return Err("file_path 不能为空".to_string());
}
// 路径穿越防御:`..` 段一律拒(与 read_module_file 同规则)。
if has_path_traversal(&file_path) {
return Err("file_path 不允许包含 ..".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
if !code_intel::is_supported_ext(&ext) {
// 不支持的语言不读文件(省 IO),supported=false 供前端隐藏符号面板。
return Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"symbols": [],
"supported": false,
}));
}
let root = PathBuf::from(&module.path);
let abs = root.join(&file_path);
if !abs.is_file() {
return Err(format!("文件不存在或不是普通文件: {}", file_path));
}
const MAX_BYTES: usize = 256 * 1024; // 256KB 上限(仅头部符号)
let abs_clone = abs.clone();
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
use std::io::Read;
let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?;
let mut buf = vec![0u8; MAX_BYTES];
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
buf.truncate(n);
Ok(buf)
})
.await
.map_err(|e| format!("读文件任务失败: {e}"))??;
let content = String::from_utf8_lossy(&bytes).to_string();
let symbols = code_intel::extract_outline(&content, &ext);
Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"symbols": symbols,
"supported": true,
}))
}
/// 查询工程内文件元信息(不读内容,轻量检测变化用)。
/// 返回 { path, size, modified_at }。modified_at 是毫秒级时间戳。
#[tauri::command]