优化: 文件预览大纲提取 + 预览/图标/滚动条打磨
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -389,6 +389,7 @@ pub fn run() {
|
||||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||||
commands::module::get_module_file_tree,
|
||||
commands::module::read_module_file,
|
||||
commands::module::file_outline,
|
||||
commands::module::get_module_file_meta,
|
||||
commands::module::get_module_file_diff,
|
||||
commands::module::get_module_commits,
|
||||
|
||||
@@ -60,6 +60,26 @@ export interface ReadFileResult {
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** fileOutline 返回的单个顶层符号。 */
|
||||
export interface OutlineSymbol {
|
||||
/** 符号名(如匿名 impl 可能为空) */
|
||||
name: string
|
||||
/** 定义节点 kind(function_item / class_declaration 等) */
|
||||
kind: string
|
||||
/** 定义起始行号(1-based) */
|
||||
line: number
|
||||
/** 签名(到首个 { 或 ; 为止) */
|
||||
signature: string
|
||||
}
|
||||
|
||||
/** fileOutline 返回结构。 */
|
||||
export interface FileOutlineResult {
|
||||
path: string
|
||||
symbols: OutlineSymbol[]
|
||||
/** 是否为支持语言(不支持时 symbols 为空) */
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
/** Git 改动文件项(对齐后端 GitChangedFile)。 */
|
||||
export interface GitChangedFile {
|
||||
status: string
|
||||
@@ -149,6 +169,14 @@ export const moduleApi = {
|
||||
return invoke('read_module_file', { moduleId, filePath })
|
||||
},
|
||||
|
||||
/**
|
||||
* 提取文件符号概览(顶层定义符号列表,供预览面板做符号树)。
|
||||
* 不支持的语言返 supported=false + 空 symbols。
|
||||
*/
|
||||
fileOutline(moduleId: string, filePath: string): Promise<FileOutlineResult> {
|
||||
return invoke('file_outline', { moduleId, filePath })
|
||||
},
|
||||
|
||||
/** 查询工程内文件元信息(不读内容,前端检测外部变化用)。返回 { path, size, modified_at }。 */
|
||||
getModuleFileMeta(moduleId: string, filePath: string): Promise<{ path: string; size: number; modified_at: number }> {
|
||||
return invoke('get_module_file_meta', { moduleId, filePath })
|
||||
|
||||
@@ -772,7 +772,7 @@ async function onRemoveModule() {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 4px 8px 0;
|
||||
padding: 4px 4px 4px 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@@ -854,19 +854,42 @@ async function onRemoveModule() {
|
||||
|
||||
/* 全局滚动条样式(薄) */
|
||||
.explorer-sidebar ::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.explorer-sidebar ::-webkit-scrollbar-thumb {
|
||||
background: var(--df-border);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.explorer-sidebar ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
|
||||
.explorer-tree ::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.explorer-tree ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.explorer-tree ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.explorer-tree ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 窄窗口自适应 */
|
||||
@media (max-width: 900px) {
|
||||
.explorer-sidebar {
|
||||
|
||||
@@ -48,10 +48,28 @@
|
||||
<!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
|
||||
<div v-else-if="error && !isExternalDiff" class="preview-error">⚠ {{ error }}</div>
|
||||
|
||||
<!-- 二进制(外部 diff 存在时让位) -->
|
||||
<!-- 二进制:展示文件信息(路径/大小/类型),替代"不支持预览"空态 -->
|
||||
<div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
|
||||
<span class="binary-icon">📄</span>
|
||||
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
|
||||
<span class="binary-icon">🗂️</span>
|
||||
<p class="binary-title">{{ $t('fileExplorer.binaryInfo') }}</p>
|
||||
<div class="binary-meta">
|
||||
<div class="binary-meta-row">
|
||||
<span class="binary-meta-label">{{ $t('fileExplorer.pathLabel') }}</span>
|
||||
<span class="binary-meta-value">{{ filePath }}</span>
|
||||
</div>
|
||||
<div class="binary-meta-row">
|
||||
<span class="binary-meta-label">{{ $t('fileExplorer.sizeLabel') }}</span>
|
||||
<span class="binary-meta-value">{{ formatSize(fileSize ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="binary-meta-row">
|
||||
<span class="binary-meta-label">{{ $t('fileExplorer.typeLabel') }}</span>
|
||||
<span class="binary-meta-value">{{ fileType }}</span>
|
||||
</div>
|
||||
<div class="binary-meta-row">
|
||||
<span class="binary-meta-label">{{ $t('fileExplorer.binaryLabel') }}</span>
|
||||
<span class="binary-meta-value">{{ $t('fileExplorer.binaryNotSupported') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片(外部 diff 存在时让位) -->
|
||||
@@ -76,19 +94,60 @@
|
||||
<div v-else-if="isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||
|
||||
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
||||
<div v-else class="preview-code-scroll">
|
||||
<div v-else ref="codeScrollRef" class="preview-code-scroll">
|
||||
<div class="preview-line-numbers" aria-hidden="true">
|
||||
<div v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div>
|
||||
</div>
|
||||
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
|
||||
</div>
|
||||
|
||||
<!-- 符号概览:悬浮右上角(折叠态为 ☰ 按钮,点击弹出分组面板) -->
|
||||
<div v-if="showOutline" class="outline-overlay">
|
||||
<button
|
||||
v-if="outlineCollapsed"
|
||||
class="outline-overlay-btn"
|
||||
:title="$t('fileExplorer.symbols')"
|
||||
@click="outlineCollapsed = false"
|
||||
>
|
||||
☰ <span class="outline-overlay-count">{{ outlineSymbols.length }}</span>
|
||||
</button>
|
||||
<div v-else class="preview-outline">
|
||||
<div class="preview-outline-head" @click="outlineCollapsed = true">
|
||||
<span class="preview-outline-title">☰ {{ $t('fileExplorer.symbols') }}</span>
|
||||
<span class="preview-outline-count">{{ outlineSymbols.length }}</span>
|
||||
<span class="preview-outline-spacer"></span>
|
||||
<span class="preview-outline-toggle">✕</span>
|
||||
</div>
|
||||
<div class="preview-outline-body">
|
||||
<!-- 分组展示:类型/函数/变量/模块,组内按行号(DFS 已保序) -->
|
||||
<div v-for="grp in outlineGroups" :key="grp.key" class="outline-group">
|
||||
<div class="outline-group-head">
|
||||
<span class="outline-group-label" :class="'grp-' + grp.key">{{ $t(grp.labelKey) }}</span>
|
||||
<span class="outline-group-count">{{ grp.items.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="sym in grp.items"
|
||||
:key="sym.line"
|
||||
class="outline-item"
|
||||
:class="['grp-' + grp.key, { 'is-active': activeOutlineLine === sym.line }]"
|
||||
:title="sym.signature"
|
||||
@click="gotoOutlineLine(sym.line)"
|
||||
>
|
||||
<span class="outline-kind">{{ outlineKindLabel(sym.kind) }}</span>
|
||||
<span class="outline-name">{{ sym.name || sym.signature || '?' }}</span>
|
||||
<span class="outline-line">{{ sym.line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
|
||||
import { moduleApi } from '@/api/module'
|
||||
import { moduleApi, type OutlineSymbol } from '@/api/module'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||||
import { escapeHtml } from '@/utils/html'
|
||||
@@ -113,6 +172,22 @@ const fileSize = ref<number | null>(null)
|
||||
const truncated = ref(false)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
/** 符号概览(fileOutline 结果;仅支持语言 + 非 diff 视图时展示)。 */
|
||||
const outlineSupported = ref(false)
|
||||
const outlineSymbols = ref<OutlineSymbol[]>([])
|
||||
const outlineCollapsed = ref(true)
|
||||
const activeOutlineLine = ref(-1)
|
||||
const codeScrollRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 文件类型(从扩展名推断,用于二进制文件信息展示)。 */
|
||||
const fileType = computed(() => {
|
||||
if (!props.filePath) return '—'
|
||||
const name = props.filePath.split('/').pop() || props.filePath
|
||||
const dot = name.lastIndexOf('.')
|
||||
const ext = dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
|
||||
return ext.length <= 6 ? ext : 'FILE'
|
||||
})
|
||||
|
||||
/** 行号显示 — 按源文本真实行数计算(非 highlight.js 渲染 HTML 行数)。
|
||||
* 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位。
|
||||
* 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染。 */
|
||||
@@ -296,6 +371,99 @@ const isImage = computed(() => {
|
||||
return IMAGE_EXT.some((ext) => lower.endsWith(ext))
|
||||
})
|
||||
|
||||
/** 符号概览面板是否展示:支持语言 + 有符号 + 非 diff 视图。 */
|
||||
const showOutline = computed(() =>
|
||||
outlineSupported.value && outlineSymbols.value.length > 0 && !showDiff.value && !!props.filePath
|
||||
)
|
||||
|
||||
/** outline 符号 kind → 短标签(顶部 chips 显示)。 */
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
function_item: 'fn', function_declaration: 'fn', function_definition: 'def',
|
||||
method_declaration: 'fn', method_definition: 'fn',
|
||||
struct_item: 'struct', class_declaration: 'class', class_definition: 'class',
|
||||
enum_item: 'enum', enum_declaration: 'enum', impl_item: 'impl',
|
||||
trait_item: 'trait', interface_declaration: 'interface', mod_item: 'mod',
|
||||
const_item: 'const', static_item: 'static', type_item: 'type',
|
||||
macro_definition: 'macro', variable_declarator: 'let', type_declaration: 'type',
|
||||
constructor_declaration: 'ctor', record_declaration: 'record',
|
||||
annotation_type_declaration: '@interface',
|
||||
}
|
||||
function outlineKindLabel(kind: string): string {
|
||||
return KIND_LABELS[kind] || kind
|
||||
}
|
||||
|
||||
/** kind → 展示分组(对齐 IDE 大纲语义:类型/函数/变量/模块)。 */
|
||||
const KIND_GROUPS: Record<string, string> = {
|
||||
// 类型
|
||||
struct_item: 'type', class_declaration: 'type', class_definition: 'type',
|
||||
enum_item: 'type', enum_declaration: 'type', trait_item: 'type',
|
||||
interface_declaration: 'type', type_item: 'type', type_declaration: 'type',
|
||||
impl_item: 'type', record_declaration: 'type', annotation_type_declaration: 'type',
|
||||
// 函数
|
||||
function_item: 'fn', function_declaration: 'fn', function_definition: 'fn',
|
||||
method_declaration: 'fn', method_definition: 'fn', constructor_declaration: 'fn',
|
||||
macro_definition: 'fn',
|
||||
// 变量
|
||||
const_item: 'var', static_item: 'var', variable_declarator: 'var',
|
||||
// 模块
|
||||
mod_item: 'mod',
|
||||
}
|
||||
/** 分组展示顺序。 */
|
||||
const GROUP_ORDER = ['type', 'fn', 'var', 'mod', 'other']
|
||||
/** 分组 → i18n key(模板 $t 解析)。 */
|
||||
const GROUP_LABEL_KEY: Record<string, string> = {
|
||||
type: 'fileExplorer.outlineGroupType',
|
||||
fn: 'fileExplorer.outlineGroupFn',
|
||||
var: 'fileExplorer.outlineGroupVar',
|
||||
mod: 'fileExplorer.outlineGroupMod',
|
||||
other: 'fileExplorer.outlineGroupOther',
|
||||
}
|
||||
function kindGroup(kind: string): string {
|
||||
return KIND_GROUPS[kind] || 'other'
|
||||
}
|
||||
|
||||
/** 按分组聚合 outline 符号(组序固定,组内按行号)。 */
|
||||
const outlineGroups = computed(() => {
|
||||
const buckets = new Map<string, OutlineSymbol[]>()
|
||||
for (const sym of outlineSymbols.value) {
|
||||
const g = kindGroup(sym.kind)
|
||||
if (!buckets.has(g)) buckets.set(g, [])
|
||||
buckets.get(g)!.push(sym)
|
||||
}
|
||||
return GROUP_ORDER
|
||||
.filter((g) => buckets.has(g))
|
||||
.map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! }))
|
||||
})
|
||||
|
||||
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12.5px × 1.55)。 */
|
||||
function gotoOutlineLine(line: number) {
|
||||
activeOutlineLine.value = line
|
||||
const el = codeScrollRef.value
|
||||
if (!el) return
|
||||
const lineHeight = 12.5 * 1.55
|
||||
const topPad = 14 // 对齐 .preview-code / .preview-line-numbers 的 padding-top
|
||||
el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' })
|
||||
}
|
||||
|
||||
/** 拉取文件符号概览(独立 try/catch:outline 失败不阻断文件内容加载)。 */
|
||||
async function loadOutline(seq: number) {
|
||||
if (!props.moduleId || !props.filePath) {
|
||||
outlineSupported.value = false
|
||||
outlineSymbols.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await moduleApi.fileOutline(props.moduleId, props.filePath)
|
||||
if (seq !== fileReqSeq) return // 文件已切换,丢弃旧 outline
|
||||
outlineSupported.value = res.supported
|
||||
outlineSymbols.value = res.symbols || []
|
||||
} catch {
|
||||
if (seq !== fileReqSeq) return
|
||||
outlineSupported.value = false
|
||||
outlineSymbols.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉文件内容 + 高亮渲染。 */
|
||||
async function loadFile() {
|
||||
const seq = ++fileReqSeq // 捕获本次请求序号(文件切换会使旧请求失效)
|
||||
@@ -303,6 +471,9 @@ async function loadFile() {
|
||||
content.value = ''
|
||||
htmlContent.value = ''
|
||||
imageUrl.value = null
|
||||
outlineSupported.value = false
|
||||
outlineSymbols.value = []
|
||||
activeOutlineLine.value = -1
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -315,8 +486,16 @@ async function loadFile() {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = null
|
||||
}
|
||||
// 重置上一文件的符号概览(新文件结果到达后再填充)
|
||||
outlineSupported.value = false
|
||||
outlineSymbols.value = []
|
||||
activeOutlineLine.value = -1
|
||||
try {
|
||||
const res = await moduleApi.readModuleFile(props.moduleId, props.filePath)
|
||||
// 内容与符号概览并行拉取(readModuleFile 与 fileOutline 同时发起)
|
||||
const [res] = await Promise.all([
|
||||
moduleApi.readModuleFile(props.moduleId, props.filePath),
|
||||
loadOutline(seq),
|
||||
])
|
||||
if (seq !== fileReqSeq) return // 旧响应晚到,丢弃不覆盖新文件内容
|
||||
fileSize.value = res.size
|
||||
truncated.value = res.truncated
|
||||
@@ -342,16 +521,35 @@ async function loadFile() {
|
||||
// highlight.js 语法高亮:已知语言精确高亮,未知语言自动检测
|
||||
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
|
||||
const lang = EXT_LANG[ext]
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
htmlContent.value = hljs.highlight(res.content, { language: lang }).value
|
||||
} else {
|
||||
htmlContent.value = hljs.highlightAuto(res.content).value
|
||||
// 先显纯文本(首屏立即),再异步高亮——大文件 hljs 高亮耗时,阻塞同步渲染会卡顿。
|
||||
// 大文件跳过 highlightAuto(自动检测慢):仅精确语言高亮,未知语言纯文本(可读,速度优先)。
|
||||
content.value = res.content
|
||||
const raw = res.content
|
||||
const isLarge = raw.length > 50_000
|
||||
const hljsTask = () => {
|
||||
if (fileReqSeq !== seq) return // 文件已切换,丢弃旧高亮
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
htmlContent.value = hljs.highlight(raw, { language: lang }).value
|
||||
} else if (!isLarge) {
|
||||
// 小文件未知语言才自动检测;大文件未知语言纯文本(避免 highlightAuto 卡顿)
|
||||
htmlContent.value = hljs.highlightAuto(raw).value
|
||||
} else {
|
||||
htmlContent.value = escapeHtml(raw)
|
||||
}
|
||||
} catch {
|
||||
htmlContent.value = escapeHtml(raw)
|
||||
}
|
||||
} catch {
|
||||
// 高亮失败 → 转义纯文本(防 XSS)
|
||||
htmlContent.value = escapeHtml(res.content)
|
||||
}
|
||||
// 高亮延后到空闲时执行(小圈不再转):rAF 让出首帧,50KB+ 用双 rAF 延后更彻底。
|
||||
requestAnimationFrame(() => {
|
||||
if (isLarge) {
|
||||
// 大文件再让一帧,确保首屏文本已渲染,高亮完全后台
|
||||
requestAnimationFrame(hljsTask)
|
||||
} else {
|
||||
hljsTask()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== fileReqSeq) return // 旧请求报错也不覆盖新状态
|
||||
@@ -468,6 +666,7 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative; /* 符号悬浮层定位锚点 */
|
||||
}
|
||||
|
||||
.preview-placeholder,
|
||||
@@ -478,11 +677,55 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 13px;
|
||||
min-height: 200px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.binary-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.binary-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binary-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
min-width: 280px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.binary-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.binary-meta-label {
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.binary-meta-value {
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono, Consolas, monospace);
|
||||
font-size: 12.5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-placeholder-icon {
|
||||
@@ -619,6 +862,173 @@ onUnmounted(() => {
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* 符号概览悬浮层:右上角按钮 + 弹出分组面板 */
|
||||
.outline-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 折叠态:右上角小 ☰ 按钮(带符号数徽标) */
|
||||
.outline-overlay-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 9px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.outline-overlay-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
.outline-overlay-count {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-radius: 7px;
|
||||
padding: 0 5px;
|
||||
font-size: 9px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* 展开态:悬浮面板(右上角弹出,固定宽 + 限高滚动 + 阴影) */
|
||||
.preview-outline {
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(20, 22, 34, 0.95);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 浅色主题:悬浮面板用浅色底 */
|
||||
[data-theme='light'] .preview-outline {
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
|
||||
.preview-outline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-outline-head:hover { color: var(--df-text); }
|
||||
|
||||
.preview-outline-title { font-weight: 600; }
|
||||
|
||||
.preview-outline-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.preview-outline-spacer { flex: 1; }
|
||||
|
||||
.preview-outline-toggle { font-size: 11px; opacity: 0.7; }
|
||||
|
||||
.preview-outline-body {
|
||||
padding: 0 0 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.outline-group { padding: 0 10px; }
|
||||
|
||||
.outline-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 2px 3px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.outline-group + .outline-group {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.outline-group-count {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 6px;
|
||||
padding: 0 5px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.outline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.outline-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--df-text); }
|
||||
|
||||
.outline-item.is-active {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--df-text);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
|
||||
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */
|
||||
.outline-item.grp-type .outline-kind { color: #4FC3F7; }
|
||||
.outline-item.grp-fn .outline-kind { color: #81C784; }
|
||||
.outline-item.grp-var .outline-kind { color: #FFB74D; }
|
||||
.outline-item.grp-mod .outline-kind { color: #BA68C8; }
|
||||
.outline-item.grp-other .outline-kind { color: var(--df-text-dim); }
|
||||
|
||||
.outline-kind {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.outline-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.outline-line {
|
||||
flex-shrink: 0;
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Diff 视图 */
|
||||
.preview-diff {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -774,4 +774,23 @@ watch(() => props.refreshKey, () => {
|
||||
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
|
||||
}
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
|
||||
.git-changes ::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.git-changes ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.git-changes ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.git-changes ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,18 @@ export default {
|
||||
selectFileHint: '← Select a file on the left to preview',
|
||||
loadingFile: 'Loading file…',
|
||||
binaryNotSupported: 'Binary file preview not supported',
|
||||
binaryInfo: 'Binary file',
|
||||
pathLabel: 'Path',
|
||||
sizeLabel: 'Size',
|
||||
typeLabel: 'Type',
|
||||
binaryLabel: 'Note',
|
||||
truncated: 'File too large, only first 1MB shown',
|
||||
symbols: 'Outline',
|
||||
outlineGroupType: 'Types',
|
||||
outlineGroupFn: 'Functions',
|
||||
outlineGroupVar: 'Variables',
|
||||
outlineGroupMod: 'Modules',
|
||||
outlineGroupOther: 'Others',
|
||||
// Errors
|
||||
loadFailed: 'Failed to load',
|
||||
// Diff
|
||||
|
||||
@@ -15,7 +15,18 @@ export default {
|
||||
selectFileHint: '← 选择左侧文件查看预览',
|
||||
loadingFile: '加载文件中…',
|
||||
binaryNotSupported: '二进制文件不支持预览',
|
||||
binaryInfo: '二进制文件',
|
||||
pathLabel: '路径',
|
||||
sizeLabel: '大小',
|
||||
typeLabel: '类型',
|
||||
binaryLabel: '说明',
|
||||
truncated: '文件过大,仅显示前 1MB',
|
||||
symbols: '大纲',
|
||||
outlineGroupType: '类型',
|
||||
outlineGroupFn: '函数',
|
||||
outlineGroupVar: '变量',
|
||||
outlineGroupMod: '模块',
|
||||
outlineGroupOther: '其他',
|
||||
// 错误
|
||||
loadFailed: '加载失败',
|
||||
// Diff
|
||||
|
||||
Reference in New Issue
Block a user