完善功能
This commit is contained in:
@@ -32,8 +32,8 @@
|
|||||||
"topic_20260710-032907_600adc6b00791607": {
|
"topic_20260710-032907_600adc6b00791607": {
|
||||||
"stage": 1,
|
"stage": 1,
|
||||||
"userTurns": 1,
|
"userTurns": 1,
|
||||||
"basisHash": "e0e08f907071b904",
|
"basisHash": "60a646e63a54c3bf",
|
||||||
"updatedAt": 1783654440818
|
"updatedAt": 1783669646165
|
||||||
},
|
},
|
||||||
"topic_20260710-034033_6432ee6566f7b432": {
|
"topic_20260710-034033_6432ee6566f7b432": {
|
||||||
"stage": 1,
|
"stage": 1,
|
||||||
@@ -52,5 +52,11 @@
|
|||||||
"userTurns": 3,
|
"userTurns": 3,
|
||||||
"basisHash": "87073c4b14751c7b",
|
"basisHash": "87073c4b14751c7b",
|
||||||
"updatedAt": 1783663776394
|
"updatedAt": 1783663776394
|
||||||
|
},
|
||||||
|
"topic_20260710-063053_353d579222d3b70b": {
|
||||||
|
"stage": 3,
|
||||||
|
"userTurns": 3,
|
||||||
|
"basisHash": "aaae120b1f33c2f2",
|
||||||
|
"updatedAt": 1783668572932
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
|
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
|
||||||
"topic_20260710-024855_5c4b1833149d66bb": "帮我给右侧的目录树添加一个功能,当用…",
|
"topic_20260710-024855_5c4b1833149d66bb": "帮我给右侧的目录树添加一个功能,当用…",
|
||||||
"topic_20260710-030936_cedad2907c3954fe": "鼠标放在tree上的时候,只有hov…",
|
"topic_20260710-030936_cedad2907c3954fe": "鼠标放在tree上的时候,只有hov…",
|
||||||
"topic_20260710-032907_600adc6b00791607": "目录树中显示的文件夹,markdow…",
|
"topic_20260710-032907_600adc6b00791607": "帮我移除目录树中在空白区域点击右键可…",
|
||||||
"topic_20260710-034033_6432ee6566f7b432": "我在点击目录树的时候新增文件夹和新建…",
|
"topic_20260710-034033_6432ee6566f7b432": "我在点击目录树的时候新增文件夹和新建…",
|
||||||
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
|
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
|
||||||
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
|
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
|
||||||
"topic_20260710-063053_353d579222d3b70b": "新的会话"
|
"topic_20260710-063053_353d579222d3b70b": "这个应用里有一个全局搜索的功能,帮我…"
|
||||||
}
|
}
|
||||||
@@ -189,3 +189,106 @@ MarkdownEditor.vue
|
|||||||
| 块 hover | `bg-[#faf9f6]` 微暖灰,提示可点击 |
|
| 块 hover | `bg-[#faf9f6]` 微暖灰,提示可点击 |
|
||||||
| 编辑 textarea | `bg-[#faf9f6]`,`border-[#bf6a3b]`,`font-mono` |
|
| 编辑 textarea | `bg-[#faf9f6]`,`border-[#bf6a3b]`,`font-mono` |
|
||||||
| 缩放 | `calc(1rem * var(--editor-zoom, 1))` CSS 变量,由 App.vue 的 Ctrl+滚轮 控制 |
|
| 缩放 | `calc(1rem * var(--editor-zoom, 1))` CSS 变量,由 App.vue 的 Ctrl+滚轮 控制 |
|
||||||
|
|
||||||
|
## 粘贴图片/文件功能
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
支持在编辑区域(edit 模式和 mixed 模式)中通过 `Ctrl+V` 粘贴剪贴板中的图片或文件。媒体文件自动拷贝到与 markdown 文件同级的隐藏资源文件夹中,编辑区域插入 markdown 图片语法引用。
|
||||||
|
|
||||||
|
### 隐藏资源文件夹约定
|
||||||
|
|
||||||
|
```
|
||||||
|
工作目录/
|
||||||
|
├── doc.md ← markdown 文档
|
||||||
|
└── .doc/ ← 隐藏资源文件夹(. + 文件名 stem)
|
||||||
|
├── 20260710_140530_a1b2.png
|
||||||
|
└── 20260710_140531_c3d4.png
|
||||||
|
```
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 命名 | `{md文件名}` → `.{stem}/`(如 `test.md` → `.test/`) |
|
||||||
|
| 可见性 | macOS/Linux:`.` 开头自动隐藏;Windows:Rust 端调用 `attrib +h` 设置隐藏属性 |
|
||||||
|
| 生命周期 | 随 md 文件创建/删除/重命名自动联动 |
|
||||||
|
| 引用方式 | markdown 中使用相对路径 `` |
|
||||||
|
|
||||||
|
### 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 Ctrl+V 粘贴图片
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ textarea @paste 事件 │
|
||||||
|
│ 检查 clipboardData.items │
|
||||||
|
│ ├─ image/png, image/jpeg, ... │ ← 截图粘贴
|
||||||
|
│ └─ kind === 'file' │ ← 文件管理器复制粘贴
|
||||||
|
│ │
|
||||||
|
│ 如有媒体项 → e.preventDefault() │
|
||||||
|
│ 无媒体项 → 走浏览器默认文本粘贴 │
|
||||||
|
└──────────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 前端 processPaste() │
|
||||||
|
│ ├─ FileReader.readAsDataURL(blob) │
|
||||||
|
│ ├─ 去掉 "data:xxx;base64," 前缀 │
|
||||||
|
│ ├─ invoke("save_media_file", { │
|
||||||
|
│ │ path: ".doc/20260710_xxx.png", │
|
||||||
|
│ │ data: "base64string" │
|
||||||
|
│ │ }) │
|
||||||
|
│ └─ 光标处插入  │
|
||||||
|
└──────────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Rust save_media_file │
|
||||||
|
│ ├─ base64 decode → Vec<u8> │
|
||||||
|
│ ├─ ensure_resources_dir() 创建目录 │
|
||||||
|
│ │ └─ Windows: attrib +h 隐藏 │
|
||||||
|
│ └─ fs::write() 写入文件 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端关键函数
|
||||||
|
|
||||||
|
| 函数 | 位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `processPaste()` | `MarkdownEditor.vue` | 核心粘贴处理:检测剪贴板媒体 → 生成文件名 → 调 Rust 保存 → 拼接 markdown 语法 |
|
||||||
|
| `blobToBase64()` | 同上 | FileReader 读取 blob 为去前缀 base64 字符串 |
|
||||||
|
| `generateMediaFilename()` | 同上 | `YYYYMMDD_HHmmss_随机4位.ext` |
|
||||||
|
| `imageExtFromMime()` | 同上 | MIME type → 文件扩展名映射 |
|
||||||
|
| `onEditorPaste()` | 同上 | edit 模式 textarea 的 paste handler |
|
||||||
|
| `onBlockPaste()` | 同上 | mixed 模式 block textarea 的 paste handler |
|
||||||
|
|
||||||
|
### Rust 端关键函数
|
||||||
|
|
||||||
|
| 命令/函数 | 位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `save_media_file(path, data)` | `lib.rs` | base64 解码 → 创建目录(+Windows 隐藏) → 写入文件 |
|
||||||
|
| `resources_dir_path(file_path)` | 同上 | `a/b/doc.md` → `a/b/.doc`(仅 .md 文件) |
|
||||||
|
| `ensure_resources_dir(dir_path)` | 同上 | 创建目录 + Windows `attrib +h` 隐藏 |
|
||||||
|
| `hide_windows_dir(dir_path)` | 同上 | `#[cfg(windows)]` 调用 `attrib +h` |
|
||||||
|
|
||||||
|
### 生命周期联动
|
||||||
|
|
||||||
|
| 操作 | Rust 行为 | 前端行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 新建 `doc.md` | `create_file` 同时调用 `ensure_resources_dir` 创建 `.doc/` | 无额外操作 |
|
||||||
|
| 删除 `doc.md` | `delete_entry` 检查并删除 `.doc/`(如存在) | App.vue 关闭文件引用 |
|
||||||
|
| 重命名 `doc.md` → `new.md` | `rename_entry` 检查并重命名 `.doc/` → `.new/` | App.vue 更新 `currentFilePath` |
|
||||||
|
| 粘贴图片时目录被删 | `save_media_file` → `ensure_resources_dir` 自动重建 `.doc/` | 正常写入 |
|
||||||
|
|
||||||
|
### 编辑模式兼容
|
||||||
|
|
||||||
|
paste 事件同时绑定在两种模式的 textarea 上:
|
||||||
|
|
||||||
|
- **edit 模式**:`@paste="onEditorPaste"` — 操作 `props.modelValue` + `editorTextareaRef`
|
||||||
|
- **mixed 模式**:`@paste="onBlockPaste"` — 操作 `editingSource` + `blockTextareaRef`(包含空文档和 block 编辑两种场景)
|
||||||
|
|
||||||
|
粘贴后的 markdown 语法统一为:
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
多张图片粘贴时用换行分隔。视频文件使用相同的 `` 语法,由 `remarkLocalMedia` 插件根据扩展名自动转为 `<video>` 标签。
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -4932,6 +4932,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"notify",
|
"notify",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
time = ">=0.3.0, <0.3.47"
|
time = ">=0.3.0, <0.3.47"
|
||||||
notify = "8.2.0"
|
notify = "8.2.0"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
|||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
struct DirEntry {
|
struct DirEntry {
|
||||||
@@ -33,6 +34,23 @@ struct WorkspaceWatchErrorPayload {
|
|||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
struct SearchMatch {
|
||||||
|
line: usize,
|
||||||
|
column: usize,
|
||||||
|
line_text: String,
|
||||||
|
context_before: Vec<String>,
|
||||||
|
context_after: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
struct FileSearchResult {
|
||||||
|
relative_path: String,
|
||||||
|
absolute_path: String,
|
||||||
|
matches: Vec<SearchMatch>,
|
||||||
|
match_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
fn is_hidden_entry(entry: &fs::DirEntry) -> bool {
|
fn is_hidden_entry(entry: &fs::DirEntry) -> bool {
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let is_dot_hidden = name.to_string_lossy().starts_with('.');
|
let is_dot_hidden = name.to_string_lossy().starts_with('.');
|
||||||
@@ -312,6 +330,162 @@ fn rename_entry(path: String, new_name: String) -> Result<String, String> {
|
|||||||
Ok(new_path.to_string_lossy().to_string())
|
Ok(new_path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn search_files(
|
||||||
|
workspace: String,
|
||||||
|
keyword: String,
|
||||||
|
case_sensitive: bool,
|
||||||
|
use_regex: bool,
|
||||||
|
whole_word: bool,
|
||||||
|
extensions: Vec<String>,
|
||||||
|
) -> Result<Vec<FileSearchResult>, String> {
|
||||||
|
if keyword.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern = if use_regex {
|
||||||
|
keyword.clone()
|
||||||
|
} else {
|
||||||
|
regex::escape(&keyword)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = regex::RegexBuilder::new(&pattern);
|
||||||
|
builder.case_insensitive(!case_sensitive);
|
||||||
|
let re = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("正则表达式无效: {}", e))?;
|
||||||
|
|
||||||
|
let mut results: Vec<FileSearchResult> = Vec::new();
|
||||||
|
let workspace_path = std::path::Path::new(&workspace);
|
||||||
|
|
||||||
|
walk_dir(
|
||||||
|
workspace_path,
|
||||||
|
workspace_path,
|
||||||
|
&re,
|
||||||
|
whole_word,
|
||||||
|
&extensions,
|
||||||
|
&mut results,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
results.sort_by(|a, b| b.match_count.cmp(&a.match_count));
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_dir(
|
||||||
|
base: &std::path::Path,
|
||||||
|
dir: &std::path::Path,
|
||||||
|
re: &Regex,
|
||||||
|
whole_word: bool,
|
||||||
|
extensions: &[String],
|
||||||
|
results: &mut Vec<FileSearchResult>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entries = fs::read_dir(dir).map_err(|e| format!("无法读取目录: {}", e))?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?;
|
||||||
|
if is_hidden_entry(&entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
walk_dir(base, &path, re, whole_word, extensions, results)?;
|
||||||
|
} else if path.is_file() {
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
if !extensions.iter().any(|e| e == &ext) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match fs::read_to_string(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
let mut matches: Vec<SearchMatch> = Vec::new();
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let has_match = if whole_word {
|
||||||
|
re.find_iter(line).any(|m| {
|
||||||
|
let start = m.start();
|
||||||
|
let end = m.end();
|
||||||
|
let prev_char = line[..start]
|
||||||
|
.chars()
|
||||||
|
.last()
|
||||||
|
.map(|c| !c.is_alphanumeric() && c != '_')
|
||||||
|
.unwrap_or(true);
|
||||||
|
let next_char = line[end..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| !c.is_alphanumeric() && c != '_')
|
||||||
|
.unwrap_or(true);
|
||||||
|
prev_char && next_char
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
re.is_match(line)
|
||||||
|
};
|
||||||
|
|
||||||
|
if has_match {
|
||||||
|
let column = re
|
||||||
|
.find(line)
|
||||||
|
.map(|m| line[..m.start()].chars().count() + 1)
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
let context_before: Vec<String> = (0..i)
|
||||||
|
.rev()
|
||||||
|
.take(2)
|
||||||
|
.map(|j| lines[j].to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let context_after: Vec<String> = lines
|
||||||
|
.iter()
|
||||||
|
.skip(i + 1)
|
||||||
|
.take(2)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
matches.push(SearchMatch {
|
||||||
|
line: i + 1,
|
||||||
|
column,
|
||||||
|
line_text: line.to_string(),
|
||||||
|
context_before,
|
||||||
|
context_after,
|
||||||
|
});
|
||||||
|
|
||||||
|
if matches.len() >= 100 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches.is_empty() {
|
||||||
|
let relative_path = path
|
||||||
|
.strip_prefix(base)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let match_count = matches.len();
|
||||||
|
|
||||||
|
results.push(FileSearchResult {
|
||||||
|
relative_path,
|
||||||
|
absolute_path: path.to_string_lossy().to_string(),
|
||||||
|
matches,
|
||||||
|
match_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||||
@@ -336,7 +510,8 @@ pub fn run() {
|
|||||||
create_file,
|
create_file,
|
||||||
delete_entry,
|
delete_entry,
|
||||||
rename_entry,
|
rename_entry,
|
||||||
save_media_file
|
save_media_file,
|
||||||
|
search_files
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// ---- File menu ----
|
// ---- File menu ----
|
||||||
@@ -400,9 +575,14 @@ pub fn run() {
|
|||||||
let toggle_outline = MenuItemBuilder::with_id("toggle_outline", "切换大纲")
|
let toggle_outline = MenuItemBuilder::with_id("toggle_outline", "切换大纲")
|
||||||
.accelerator("CmdOrCtrl+B")
|
.accelerator("CmdOrCtrl+B")
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
let search = MenuItemBuilder::with_id("search", "搜索")
|
||||||
|
.accelerator("CmdOrCtrl+Shift+F")
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
let view_menu = SubmenuBuilder::new(app, "视图")
|
let view_menu = SubmenuBuilder::new(app, "视图")
|
||||||
.item(&toggle_outline)
|
.item(&toggle_outline)
|
||||||
|
.separator()
|
||||||
|
.item(&search)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
// ---- Help menu ----
|
// ---- Help menu ----
|
||||||
@@ -453,6 +633,9 @@ pub fn run() {
|
|||||||
"about" => {
|
"about" => {
|
||||||
let _ = app_handle.emit("show-about", ());
|
let _ = app_handle.emit("show-about", ());
|
||||||
}
|
}
|
||||||
|
"search" => {
|
||||||
|
let _ = app_handle.emit("menu-search", ());
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Forward other menu events to frontend
|
// Forward other menu events to frontend
|
||||||
let _ = app_handle.emit("menu-event", id);
|
let _ = app_handle.emit("menu-event", id);
|
||||||
|
|||||||
16
src/App.vue
16
src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch, onMounted, onUnmounted} from "vue";
|
import {computed, ref, watch, onMounted, onUnmounted, nextTick} from "vue";
|
||||||
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
|
import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event";
|
||||||
import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
|
import {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
|
||||||
import {getCurrentWindow} from "@tauri-apps/api/window";
|
import {getCurrentWindow} from "@tauri-apps/api/window";
|
||||||
@@ -173,7 +173,8 @@ const appTheme = ref<AppTheme>(loadAppTheme());
|
|||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
const settingsRequest = ref(0);
|
const settingsRequest = ref(0);
|
||||||
const settingsInitialPage = ref<"about" | "editor">("about");
|
const settingsInitialPage = ref<"about" | "editor">("about");
|
||||||
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined; scrollToLine: (line: number) => void } | null>(null);
|
||||||
|
const directorySidebarRef = ref<{ activateSearch: () => void } | null>(null);
|
||||||
const isWindowMaximized = ref(false);
|
const isWindowMaximized = ref(false);
|
||||||
const isMarkdownDocument = computed(() => currentFileType.value === "markdown");
|
const isMarkdownDocument = computed(() => currentFileType.value === "markdown");
|
||||||
const currentVideoMimeType = computed(() => {
|
const currentVideoMimeType = computed(() => {
|
||||||
@@ -984,6 +985,15 @@ registerTauriListener("show-about", () => {
|
|||||||
registerTauriListener("menu-event", (event) => {
|
registerTauriListener("menu-event", (event) => {
|
||||||
console.log("Menu event:", event.payload);
|
console.log("Menu event:", event.payload);
|
||||||
});
|
});
|
||||||
|
registerTauriListener("menu-search", () => {
|
||||||
|
directorySidebarRef.value?.activateSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSearchResultClick(filePath: string, lineNumber: number) {
|
||||||
|
await onOpenFile(filePath);
|
||||||
|
await nextTick();
|
||||||
|
markdownEditorRef.value?.scrollToLine(lineNumber);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -992,6 +1002,7 @@ registerTauriListener("menu-event", (event) => {
|
|||||||
<div class="flex-1 flex overflow-hidden app-root">
|
<div class="flex-1 flex overflow-hidden app-root">
|
||||||
<!-- Left: Directory sidebar -->
|
<!-- Left: Directory sidebar -->
|
||||||
<DirectorySidebar
|
<DirectorySidebar
|
||||||
|
ref="directorySidebarRef"
|
||||||
:folder-path="folderPath"
|
:folder-path="folderPath"
|
||||||
:entries="dirEntries"
|
:entries="dirEntries"
|
||||||
:current-file-path="currentFilePath"
|
:current-file-path="currentFilePath"
|
||||||
@@ -1009,6 +1020,7 @@ registerTauriListener("menu-event", (event) => {
|
|||||||
@create-file="onCreateFile"
|
@create-file="onCreateFile"
|
||||||
@delete-entry="onDeleteEntry"
|
@delete-entry="onDeleteEntry"
|
||||||
@rename-entry="onRenameEntry"
|
@rename-entry="onRenameEntry"
|
||||||
|
@search-result-click="handleSearchResultClick"
|
||||||
@update-default-document-mode="updateDefaultDocumentMode"
|
@update-default-document-mode="updateDefaultDocumentMode"
|
||||||
@update-app-theme="updateAppTheme"
|
@update-app-theme="updateAppTheme"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, onBeforeUnmount, watch} from "vue";
|
import {ref, computed, onBeforeUnmount, watch, nextTick} from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import SettingsModal from "./SettingsModal.vue";
|
import SettingsModal from "./SettingsModal.vue";
|
||||||
import TreeEntry from "./TreeEntry.vue";
|
import TreeEntry from "./TreeEntry.vue";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface WorkspaceEntry {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
@@ -52,24 +54,225 @@ const emit = defineEmits<{
|
|||||||
renameEntry: [path: string, newName: string];
|
renameEntry: [path: string, newName: string];
|
||||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||||
updateAppTheme: [theme: AppTheme];
|
updateAppTheme: [theme: AppTheme];
|
||||||
|
"search-result-click": [filePath: string, lineNumber: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Wrap the workspace root as a top-level tree node
|
|
||||||
const rootEntry = computed<DirEntry | null>(() => {
|
|
||||||
if (!props.folderPath) return null;
|
|
||||||
const name = props.folderPath.split(/[/\\]/).pop() || props.folderPath;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: props.folderPath,
|
|
||||||
is_dir: true,
|
|
||||||
children: props.entries,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- sidebar view mode ----
|
// ---- sidebar view mode ----
|
||||||
type ViewMode = "folder" | "search" | "setting";
|
type ViewMode = "folder" | "search" | "setting";
|
||||||
const activeView = ref<ViewMode>("folder");
|
const activeView = ref<ViewMode>("folder");
|
||||||
|
|
||||||
|
// ---- search types ----
|
||||||
|
interface SearchMatch {
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
line_text: string;
|
||||||
|
context_before: string[];
|
||||||
|
context_after: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSearchResult {
|
||||||
|
relative_path: string;
|
||||||
|
absolute_path: string;
|
||||||
|
matches: SearchMatch[];
|
||||||
|
match_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_HISTORY_KEY = "yurou:search-history";
|
||||||
|
const MAX_SEARCH_HISTORY = 10;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
const searchKeyword = ref("");
|
||||||
|
const searchCaseSensitive = ref(false);
|
||||||
|
const searchUseRegex = ref(false);
|
||||||
|
const searchWholeWord = ref(false);
|
||||||
|
const searchExtensions = ref<string[]>(["md"]);
|
||||||
|
const searchResults = ref<FileSearchResult[]>([]);
|
||||||
|
const searchLoading = ref(false);
|
||||||
|
const searchError = ref("");
|
||||||
|
const searchHistory = ref<string[]>(loadSearchHistory());
|
||||||
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const searchFocusedFile = ref<string | null>(null);
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const searchableTypes = [
|
||||||
|
{ label: "Markdown (.md)", value: "md" },
|
||||||
|
{ label: "纯文本 (.txt)", value: "txt" },
|
||||||
|
{ label: "JSON (.json)", value: "json" },
|
||||||
|
{ label: "YAML (.yaml/.yml)", value: "yaml" },
|
||||||
|
{ label: "JavaScript (.js)", value: "js" },
|
||||||
|
{ label: "TypeScript (.ts)", value: "ts" },
|
||||||
|
{ label: "CSS (.css)", value: "css" },
|
||||||
|
{ label: "HTML (.html)", value: "html" },
|
||||||
|
{ label: "XML (.xml)", value: "xml" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const showExtensionDropdown = ref(false);
|
||||||
|
const extensionDropdownRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const totalMatchCount = computed(() => {
|
||||||
|
return searchResults.value.reduce((sum, f) => sum + f.match_count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadSearchHistory(): string[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSearchHistory() {
|
||||||
|
try {
|
||||||
|
const keyword = searchKeyword.value.trim();
|
||||||
|
if (!keyword) return;
|
||||||
|
const history = searchHistory.value.filter((h) => h !== keyword);
|
||||||
|
history.unshift(keyword);
|
||||||
|
const trimmed = history.slice(0, MAX_SEARCH_HISTORY);
|
||||||
|
searchHistory.value = trimmed;
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(trimmed));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSearch() {
|
||||||
|
const keyword = searchKeyword.value.trim();
|
||||||
|
if (!keyword || !props.folderPath) {
|
||||||
|
searchResults.value = [];
|
||||||
|
searchError.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchLoading.value = true;
|
||||||
|
searchError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await invoke<FileSearchResult[]>("search_files", {
|
||||||
|
workspace: props.folderPath,
|
||||||
|
keyword,
|
||||||
|
caseSensitive: searchCaseSensitive.value,
|
||||||
|
useRegex: searchUseRegex.value,
|
||||||
|
wholeWord: searchWholeWord.value,
|
||||||
|
extensions: searchExtensions.value,
|
||||||
|
});
|
||||||
|
searchResults.value = results;
|
||||||
|
if (results.length === 0) {
|
||||||
|
searchError.value = "未找到匹配结果";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
searchError.value = `搜索出错: ${e}`;
|
||||||
|
searchResults.value = [];
|
||||||
|
} finally {
|
||||||
|
searchLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSearch() {
|
||||||
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
void performSearch();
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchKeyword.value = "";
|
||||||
|
searchResults.value = [];
|
||||||
|
searchError.value = "";
|
||||||
|
searchFocusedFile.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExtension(ext: string) {
|
||||||
|
const idx = searchExtensions.value.indexOf(ext);
|
||||||
|
if (idx >= 0) {
|
||||||
|
if (searchExtensions.value.length > 1) {
|
||||||
|
searchExtensions.value.splice(idx, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchExtensions.value = [...searchExtensions.value, ext];
|
||||||
|
}
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
clearSearch();
|
||||||
|
} else if (e.key === "Enter" && searchResults.value.length > 0) {
|
||||||
|
const first = searchResults.value[0];
|
||||||
|
if (first.matches.length > 0) {
|
||||||
|
saveSearchHistory();
|
||||||
|
emit("search-result-click", first.absolute_path, first.matches[0].line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResultClick(file: FileSearchResult, match: SearchMatch) {
|
||||||
|
saveSearchHistory();
|
||||||
|
emit("search-result-click", file.absolute_path, match.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFileCollapse(relativePath: string) {
|
||||||
|
searchFocusedFile.value = searchFocusedFile.value === relativePath ? null : relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearchInput() {
|
||||||
|
void nextTick(() => {
|
||||||
|
searchInputRef.value?.focus();
|
||||||
|
searchInputRef.value?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSearch() {
|
||||||
|
if (activeView.value !== "search") {
|
||||||
|
activeView.value = "search";
|
||||||
|
if (isCollapsed.value) expand();
|
||||||
|
}
|
||||||
|
focusSearchInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMatchText(text: string): string {
|
||||||
|
const keyword = searchKeyword.value.trim();
|
||||||
|
if (!keyword || searchUseRegex.value) {
|
||||||
|
return escapeHtml(text);
|
||||||
|
}
|
||||||
|
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
try {
|
||||||
|
const flags = searchCaseSensitive.value ? "g" : "gi";
|
||||||
|
const re = new RegExp(`(${escaped})`, flags);
|
||||||
|
return escapeHtml(text).replace(re, "<mark class='search-highlight'>$1</mark>");
|
||||||
|
} catch {
|
||||||
|
return escapeHtml(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExtensionDropdown(e: MouseEvent) {
|
||||||
|
if (extensionDropdownRef.value && !extensionDropdownRef.value.contains(e.target as Node)) {
|
||||||
|
showExtensionDropdown.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showExtensionDropdown, (open) => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => document.addEventListener("click", closeExtensionDropdown), 0);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener("click", closeExtensionDropdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([searchKeyword, searchCaseSensitive, searchUseRegex, searchWholeWord], () => {
|
||||||
|
triggerSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// collapsed computed is defined above (line ~71), reuse it
|
||||||
|
defineExpose({
|
||||||
|
activateSearch,
|
||||||
|
});
|
||||||
|
|
||||||
// ---- sidebar resize ----
|
// ---- sidebar resize ----
|
||||||
const MIN_SIDEBAR_WIDTH = 180;
|
const MIN_SIDEBAR_WIDTH = 180;
|
||||||
const MAX_SIDEBAR_WIDTH = 500;
|
const MAX_SIDEBAR_WIDTH = 500;
|
||||||
@@ -320,6 +523,8 @@ function openSettings(page: SettingsPage) {
|
|||||||
settingsPage.value = page;
|
settingsPage.value = page;
|
||||||
showSettings.value = true;
|
showSettings.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -364,11 +569,14 @@ function openSettings(page: SettingsPage) {
|
|||||||
<!-- ===== FOLDER VIEW ===== -->
|
<!-- ===== FOLDER VIEW ===== -->
|
||||||
<div v-if="activeView === 'folder'" class="flex flex-col flex-1 overflow-hidden">
|
<div v-if="activeView === 'folder'" class="flex flex-col flex-1 overflow-hidden">
|
||||||
<!-- File tree -->
|
<!-- File tree -->
|
||||||
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
<div
|
||||||
<!-- Workspace root as top-level tree node -->
|
class="flex-1 overflow-y-auto px-2 py-2 flex flex-col gap-0.5"
|
||||||
|
|
||||||
|
>
|
||||||
<TreeEntry
|
<TreeEntry
|
||||||
v-if="rootEntry"
|
v-for="entry in entries"
|
||||||
:entry="rootEntry"
|
:key="entry.path"
|
||||||
|
:entry="entry"
|
||||||
:depth="0"
|
:depth="0"
|
||||||
:currentFilePath="currentFilePath"
|
:currentFilePath="currentFilePath"
|
||||||
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
||||||
@@ -398,20 +606,179 @@ function openSettings(page: SettingsPage) {
|
|||||||
|
|
||||||
<!-- ===== SEARCH VIEW ===== -->
|
<!-- ===== SEARCH VIEW ===== -->
|
||||||
<div v-if="activeView === 'search'" class="flex flex-col flex-1 overflow-hidden">
|
<div v-if="activeView === 'search'" class="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<!-- 搜索输入区 -->
|
||||||
<div class="px-3 py-3">
|
<div class="px-3 py-3">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<i class="sidebar-search-icon ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-sm"></i>
|
<i class="sidebar-search-icon ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-sm"></i>
|
||||||
<input
|
<input
|
||||||
class="sidebar-search-input w-full pl-8 pr-3 py-1.5 text-xs rounded-md outline-none"
|
ref="searchInputRef"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
class="sidebar-search-input w-full pl-8 pr-8 py-1.5 text-xs rounded-md outline-none"
|
||||||
placeholder="搜索文件内容..."
|
placeholder="搜索文件内容..."
|
||||||
disabled
|
@keydown="handleSearchKeydown"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchKeyword"
|
||||||
|
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-xs cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 text-[var(--app-subtle)]"
|
||||||
|
title="清空"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<i class="ri-close-line"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索选项 -->
|
||||||
|
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
class="search-option-btn"
|
||||||
|
:class="{ 'search-option-active': searchCaseSensitive }"
|
||||||
|
title="区分大小写"
|
||||||
|
@click="searchCaseSensitive = !searchCaseSensitive"
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="search-option-btn"
|
||||||
|
:class="{ 'search-option-active': searchUseRegex }"
|
||||||
|
title="正则表达式"
|
||||||
|
@click="searchUseRegex = !searchUseRegex"
|
||||||
|
>
|
||||||
|
.*
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="search-option-btn"
|
||||||
|
:class="{ 'search-option-active': searchWholeWord }"
|
||||||
|
title="全词匹配"
|
||||||
|
@click="searchWholeWord = !searchWholeWord"
|
||||||
|
>
|
||||||
|
<i class="ri-font-size"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 文件类型下拉 -->
|
||||||
|
<div ref="extensionDropdownRef" class="relative">
|
||||||
|
<button
|
||||||
|
class="search-option-btn flex items-center gap-1"
|
||||||
|
@click="showExtensionDropdown = !showExtensionDropdown"
|
||||||
|
title="文件类型"
|
||||||
|
>
|
||||||
|
<span class="text-[10px]">.{{ searchExtensions.join(', .') }}</span>
|
||||||
|
<i class="ri-arrow-down-s-line text-[9px]"></i>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showExtensionDropdown"
|
||||||
|
class="search-ext-dropdown absolute top-full left-0 mt-1 bg-[var(--app-surface)] border border-[var(--app-border)] rounded-md shadow-lg z-30 py-1 min-w-[150px] max-h-52 overflow-y-auto"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="type in searchableTypes"
|
||||||
|
:key="type.value"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-[var(--app-hover)]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="searchExtensions.includes(type.value)"
|
||||||
|
class="w-3 h-3 accent-[var(--app-accent)]"
|
||||||
|
@change="toggleExtension(type.value)"
|
||||||
|
/>
|
||||||
|
{{ type.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex items-center justify-center px-3 pb-8">
|
|
||||||
<p class="sidebar-empty text-xs text-center leading-relaxed">
|
<!-- 搜索结果区域 -->
|
||||||
搜索功能即将上线
|
<div class="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
</p>
|
<!-- Loading -->
|
||||||
|
<div v-if="searchLoading" class="flex items-center justify-center py-8">
|
||||||
|
<p class="text-xs text-[var(--app-subtle)]">搜索中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error / No results -->
|
||||||
|
<div v-else-if="searchError && searchResults.length === 0" class="flex items-center justify-center py-8">
|
||||||
|
<p class="text-xs text-[var(--app-subtle)]">{{ searchError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty prompt / history -->
|
||||||
|
<div v-else-if="!searchKeyword.trim()" class="flex flex-col py-4 px-1">
|
||||||
|
<template v-if="searchHistory.length > 0">
|
||||||
|
<p class="text-[10px] text-[var(--app-subtle)] px-2 mb-2 uppercase tracking-wider">最近搜索</p>
|
||||||
|
<button
|
||||||
|
v-for="(item, idx) in searchHistory.slice(0, 5)"
|
||||||
|
:key="idx"
|
||||||
|
class="search-history-item flex items-center gap-1.5 text-xs px-2 py-1.5 rounded cursor-pointer hover:bg-[var(--app-hover)] w-full text-left truncate"
|
||||||
|
@click="searchKeyword = item"
|
||||||
|
>
|
||||||
|
<i class="ri-history-line text-[var(--app-subtle)] shrink-0"></i>
|
||||||
|
<span class="truncate text-[var(--app-text-soft)]">{{ item }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex flex-col items-center justify-center py-8">
|
||||||
|
<p v-if="props.folderPath" class="text-xs text-[var(--app-subtle)]">
|
||||||
|
输入关键词搜索工作目录中的文件
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-[var(--app-subtle)]">
|
||||||
|
请先打开一个工作目录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results list (grouped by file) -->
|
||||||
|
<div v-else class="flex flex-col gap-1">
|
||||||
|
<div class="text-[10px] text-[var(--app-subtle)] px-1 py-1">
|
||||||
|
{{ searchResults.length }} 个文件,共 {{ totalMatchCount }} 处匹配
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="file in searchResults"
|
||||||
|
:key="file.absolute_path"
|
||||||
|
class="search-file-group"
|
||||||
|
>
|
||||||
|
<!-- File header -->
|
||||||
|
<button
|
||||||
|
class="search-file-header w-full flex items-center gap-1.5 px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-[var(--app-hover)] transition-colors"
|
||||||
|
@click="toggleFileCollapse(file.relative_path)"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="text-[10px] transition-transform"
|
||||||
|
:class="searchFocusedFile === file.relative_path ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'"
|
||||||
|
></i>
|
||||||
|
<i class="ri-file-text-line text-[var(--app-subtle)] shrink-0"></i>
|
||||||
|
<span class="truncate flex-1 text-left text-[var(--app-text-soft)]">{{ file.relative_path }}</span>
|
||||||
|
<span class="text-[var(--app-subtle)] shrink-0 text-[10px]">{{ file.match_count }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- File matches (collapsible) -->
|
||||||
|
<div
|
||||||
|
v-show="searchFocusedFile !== file.relative_path"
|
||||||
|
class="search-matches ml-5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(match, mIdx) in file.matches"
|
||||||
|
:key="mIdx"
|
||||||
|
class="search-match-item w-full text-left px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-[var(--app-hover)] transition-colors block"
|
||||||
|
:class="{ 'bg-[var(--app-active-bg)]': props.currentFilePath === file.absolute_path }"
|
||||||
|
@click="onResultClick(file, match)"
|
||||||
|
>
|
||||||
|
<div class="flex items-baseline gap-2 mb-0.5">
|
||||||
|
<span class="text-[10px] text-[var(--app-accent)] shrink-0 font-mono">L{{ match.line }}</span>
|
||||||
|
<span class="truncate text-[var(--app-text-soft)]" v-html="highlightMatchText(match.line_text)"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Context lines -->
|
||||||
|
<div v-if="match.context_before.length || match.context_after.length" class="text-[10px] text-[var(--app-subtle)] mt-0.5 space-y-0">
|
||||||
|
<div v-for="(ctxLine, ci) in match.context_before" :key="'b' + ci" class="truncate opacity-60">
|
||||||
|
{{ ctxLine }}
|
||||||
|
</div>
|
||||||
|
<div v-for="(ctxLine, ci) in match.context_after" :key="'a' + ci" class="truncate opacity-60">
|
||||||
|
{{ ctxLine }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div v-if="file.match_count > file.matches.length" class="text-[10px] text-[var(--app-subtle)] px-2 py-0.5">
|
||||||
|
... 还有 {{ file.match_count - file.matches.length }} 处匹配未显示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -898,6 +1265,7 @@ function openSettings(page: SettingsPage) {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.workspace-manager-overlay {
|
.workspace-manager-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1283,4 +1651,61 @@ function openSettings(page: SettingsPage) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Search view ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.search-option-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option-btn:hover {
|
||||||
|
background: var(--app-hover);
|
||||||
|
color: var(--app-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-option-active {
|
||||||
|
background: color-mix(in srgb, var(--app-accent) 15%, transparent);
|
||||||
|
color: var(--app-accent);
|
||||||
|
border-color: color-mix(in srgb, var(--app-accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-file-header {
|
||||||
|
color: var(--app-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-match-item {
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-match-item:hover {
|
||||||
|
border-left-color: var(--app-accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-highlight {
|
||||||
|
background: color-mix(in srgb, var(--app-accent) 30%, transparent);
|
||||||
|
color: var(--app-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-history-item {
|
||||||
|
color: var(--app-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-ext-dropdown {
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -993,8 +993,47 @@ async function onBlockPaste(e: ClipboardEvent) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToLine(lineNumber: number) {
|
||||||
|
if (!scrollRef.value) return;
|
||||||
|
|
||||||
|
if (activeDocumentMode.value === "edit") {
|
||||||
|
const ta = editorTextareaRef.value;
|
||||||
|
if (ta) {
|
||||||
|
const lineHeight = 24;
|
||||||
|
const targetScroll = (lineNumber - 1) * lineHeight;
|
||||||
|
ta.scrollTop = Math.max(0, targetScroll - 80);
|
||||||
|
ta.focus();
|
||||||
|
const lines = ta.value.split("\n");
|
||||||
|
let pos = 0;
|
||||||
|
for (let i = 0; i < Math.min(lineNumber - 1, lines.length); i++) {
|
||||||
|
pos += lines[i].length + 1;
|
||||||
|
}
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const previewEl = scrollRef.value.querySelector(".markdown-preview") as HTMLElement;
|
||||||
|
if (previewEl) {
|
||||||
|
let targetHeading: HTMLElement | null = null;
|
||||||
|
for (const h of previewEl.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
|
||||||
|
const el = h as HTMLElement;
|
||||||
|
const dataLine = el.dataset?.line;
|
||||||
|
if (dataLine && parseInt(dataLine) <= lineNumber) {
|
||||||
|
targetHeading = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetHeading) {
|
||||||
|
targetHeading.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
} else {
|
||||||
|
const lineHeight = 28;
|
||||||
|
scrollRef.value.scrollTop = (lineNumber - 1) * lineHeight - 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
flushPendingChanges,
|
flushPendingChanges,
|
||||||
|
scrollToLine,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export default { name: "TreeEntry" };
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||||
import PromptDialog from "./PromptDialog.vue";
|
import PromptDialog from "./PromptDialog.vue";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,8 @@ interface DirEntry {
|
|||||||
children?: DirEntry[];
|
children?: DirEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TREE_CONTEXT_MENU_CLOSE_EVENT = "yurou-tree-context-menu-close";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: DirEntry;
|
entry: DirEntry;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -39,6 +41,44 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const expanded = ref(true);
|
const expanded = ref(true);
|
||||||
|
const contextMenuOpen = ref(false);
|
||||||
|
const contextMenuPoint = ref<{ x: number; y: number } | null>(null);
|
||||||
|
const contextMenuStyle = computed(() => {
|
||||||
|
if (!contextMenuPoint.value) return {};
|
||||||
|
return {
|
||||||
|
left: `${contextMenuPoint.value.x}px`,
|
||||||
|
top: `${contextMenuPoint.value.y}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(contextMenuOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
contextMenuPoint.value = null;
|
||||||
|
document.removeEventListener("click", closeContextMenu);
|
||||||
|
document.removeEventListener("keydown", onContextMenuKeydown);
|
||||||
|
document.removeEventListener(TREE_CONTEXT_MENU_CLOSE_EVENT, closeContextMenu);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => document.addEventListener("click", closeContextMenu), 0);
|
||||||
|
document.addEventListener("keydown", onContextMenuKeydown);
|
||||||
|
document.addEventListener(TREE_CONTEXT_MENU_CLOSE_EVENT, closeContextMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("click", closeContextMenu);
|
||||||
|
document.removeEventListener("keydown", onContextMenuKeydown);
|
||||||
|
document.removeEventListener(TREE_CONTEXT_MENU_CLOSE_EVENT, closeContextMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenuKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-expand when children are loaded for the first time
|
// Auto-expand when children are loaded for the first time
|
||||||
watch(
|
watch(
|
||||||
@@ -109,18 +149,21 @@ function onPromptCancel() {
|
|||||||
|
|
||||||
// ── Actions ─────────────────────────────────────────────────
|
// ── Actions ─────────────────────────────────────────────────
|
||||||
function handleCreateFolder() {
|
function handleCreateFolder() {
|
||||||
|
closeContextMenu();
|
||||||
openPrompt("新建文件夹", "", "输入文件夹名称", "", (name) => {
|
openPrompt("新建文件夹", "", "输入文件夹名称", "", (name) => {
|
||||||
emit("createFolder", props.entry.path + "/" + name);
|
emit("createFolder", props.entry.path + "/" + name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateFile() {
|
function handleCreateFile() {
|
||||||
|
closeContextMenu();
|
||||||
openPrompt("新建 MD 文件", "", "输入文件名称(无需 .md 后缀)", "", (name) => {
|
openPrompt("新建 MD 文件", "", "输入文件名称(无需 .md 后缀)", "", (name) => {
|
||||||
emit("createFile", props.entry.path + "/" + name + ".md");
|
emit("createFile", props.entry.path + "/" + name + ".md");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRename() {
|
function handleRename() {
|
||||||
|
closeContextMenu();
|
||||||
openPrompt("重命名", "", "输入新名称", props.entry.name, (newName) => {
|
openPrompt("重命名", "", "输入新名称", props.entry.name, (newName) => {
|
||||||
if (newName !== props.entry.name) {
|
if (newName !== props.entry.name) {
|
||||||
emit("renameEntry", props.entry.path, newName);
|
emit("renameEntry", props.entry.path, newName);
|
||||||
@@ -134,6 +177,7 @@ const confirmTitle = ref("");
|
|||||||
const confirmMessage = ref("");
|
const confirmMessage = ref("");
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
|
closeContextMenu();
|
||||||
confirmTitle.value = props.entry.is_dir ? "删除文件夹" : "删除文件";
|
confirmTitle.value = props.entry.is_dir ? "删除文件夹" : "删除文件";
|
||||||
confirmMessage.value = props.entry.is_dir
|
confirmMessage.value = props.entry.is_dir
|
||||||
? `确定要永久删除文件夹「${props.entry.name}」及其所有内容吗?此操作不可撤销。`
|
? `确定要永久删除文件夹「${props.entry.name}」及其所有内容吗?此操作不可撤销。`
|
||||||
@@ -158,6 +202,14 @@ function onClick() {
|
|||||||
emit("openFile", props.entry.path);
|
emit("openFile", props.entry.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onContextMenu(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
document.dispatchEvent(new Event(TREE_CONTEXT_MENU_CLOSE_EVENT));
|
||||||
|
contextMenuPoint.value = { x: event.clientX, y: event.clientY };
|
||||||
|
contextMenuOpen.value = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -184,12 +236,68 @@ function onClick() {
|
|||||||
@cancel="onPromptCancel"
|
@cancel="onPromptCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="contextMenuOpen"
|
||||||
|
class="tree-context-menu"
|
||||||
|
:style="contextMenuStyle"
|
||||||
|
role="menu"
|
||||||
|
@click.stop
|
||||||
|
@contextmenu.prevent.stop
|
||||||
|
>
|
||||||
|
<template v-if="entry.is_dir">
|
||||||
|
<button
|
||||||
|
class="tree-context-menu-item"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
@click="handleCreateFolder"
|
||||||
|
>
|
||||||
|
<i class="ri-folder-add-line"></i>
|
||||||
|
<span>新建文件夹</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tree-context-menu-item"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
@click="handleCreateFile"
|
||||||
|
>
|
||||||
|
<i class="ri-file-add-line"></i>
|
||||||
|
<span>新建 MD 文件</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tree-context-menu-item"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
@click="handleRename"
|
||||||
|
>
|
||||||
|
<i class="ri-edit-line"></i>
|
||||||
|
<span>重命名</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="tree-context-menu-separator"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tree-context-menu-item tree-context-menu-item-danger"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
<i class="ri-delete-bin-line"></i>
|
||||||
|
<span>{{ entry.is_dir ? "删除文件夹" : "删除" }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<template v-if="entry.is_dir">
|
<template v-if="entry.is_dir">
|
||||||
<!-- Folder row -->
|
<!-- Folder row -->
|
||||||
<div
|
<div
|
||||||
|
data-tree-entry
|
||||||
class="tree-folder flex items-center gap-1 px-2 py-1 rounded-md text-sm cursor-pointer select-none relative group"
|
class="tree-folder flex items-center gap-1 px-2 py-1 rounded-md text-sm cursor-pointer select-none relative group"
|
||||||
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
|
@contextmenu="onContextMenu"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
class="tree-arrow ri-arrow-right-s-line text-sm transition-transform shrink-0"
|
class="tree-arrow ri-arrow-right-s-line text-sm transition-transform shrink-0"
|
||||||
@@ -253,6 +361,7 @@ function onClick() {
|
|||||||
|
|
||||||
<!-- File -->
|
<!-- File -->
|
||||||
<div
|
<div
|
||||||
|
data-tree-entry
|
||||||
v-else
|
v-else
|
||||||
:class="[
|
:class="[
|
||||||
'tree-file flex items-center gap-1.5 px-2 py-1 rounded-md text-sm cursor-pointer transition-colors relative group',
|
'tree-file flex items-center gap-1.5 px-2 py-1 rounded-md text-sm cursor-pointer transition-colors relative group',
|
||||||
@@ -262,6 +371,7 @@ function onClick() {
|
|||||||
]"
|
]"
|
||||||
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
|
@contextmenu="onContextMenu"
|
||||||
>
|
>
|
||||||
<i :class="['tree-entry-icon', fileIcon, 'text-sm shrink-0']"></i>
|
<i :class="['tree-entry-icon', fileIcon, 'text-sm shrink-0']"></i>
|
||||||
<span class="truncate flex-1 min-w-0">{{ entry.name }}</span>
|
<span class="truncate flex-1 min-w-0">{{ entry.name }}</span>
|
||||||
@@ -349,4 +459,64 @@ function onClick() {
|
|||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 128px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--app-border-strong) 76%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--app-surface);
|
||||||
|
color: var(--app-text-soft);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 34px color-mix(in srgb, var(--app-shadow) 70%, transparent),
|
||||||
|
0 1px 4px color-mix(in srgb, var(--app-shadow) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-item:hover {
|
||||||
|
background: var(--app-hover);
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-item i {
|
||||||
|
color: var(--app-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-item-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-item-danger i {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-item-danger:hover {
|
||||||
|
background: color-mix(in srgb, #dc2626 10%, transparent);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-context-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px -4px;
|
||||||
|
background: var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user