完善功能
This commit is contained in:
@@ -32,8 +32,8 @@
|
||||
"topic_20260710-032907_600adc6b00791607": {
|
||||
"stage": 1,
|
||||
"userTurns": 1,
|
||||
"basisHash": "e0e08f907071b904",
|
||||
"updatedAt": 1783654440818
|
||||
"basisHash": "60a646e63a54c3bf",
|
||||
"updatedAt": 1783669646165
|
||||
},
|
||||
"topic_20260710-034033_6432ee6566f7b432": {
|
||||
"stage": 1,
|
||||
@@ -52,5 +52,11 @@
|
||||
"userTurns": 3,
|
||||
"basisHash": "87073c4b14751c7b",
|
||||
"updatedAt": 1783663776394
|
||||
},
|
||||
"topic_20260710-063053_353d579222d3b70b": {
|
||||
"stage": 3,
|
||||
"userTurns": 3,
|
||||
"basisHash": "aaae120b1f33c2f2",
|
||||
"updatedAt": 1783668572932
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,9 @@
|
||||
"topic_20260708-095428_990617acd4168b40": "帮我检查一下这个应用在进行放大缩小的…",
|
||||
"topic_20260710-024855_5c4b1833149d66bb": "帮我给右侧的目录树添加一个功能,当用…",
|
||||
"topic_20260710-030936_cedad2907c3954fe": "鼠标放在tree上的时候,只有hov…",
|
||||
"topic_20260710-032907_600adc6b00791607": "目录树中显示的文件夹,markdow…",
|
||||
"topic_20260710-032907_600adc6b00791607": "帮我移除目录树中在空白区域点击右键可…",
|
||||
"topic_20260710-034033_6432ee6566f7b432": "我在点击目录树的时候新增文件夹和新建…",
|
||||
"topic_20260710-034724_3c6fb29d8d0a4119": "目录树中鼠标悬浮弹框dropmenu…",
|
||||
"topic_20260710-054543_5e7c427932aecd7b": "现在在markdown的编辑区域中…",
|
||||
"topic_20260710-063053_353d579222d3b70b": "新的会话"
|
||||
"topic_20260710-063053_353d579222d3b70b": "这个应用里有一个全局搜索的功能,帮我…"
|
||||
}
|
||||
@@ -189,3 +189,106 @@ MarkdownEditor.vue
|
||||
| 块 hover | `bg-[#faf9f6]` 微暖灰,提示可点击 |
|
||||
| 编辑 textarea | `bg-[#faf9f6]`,`border-[#bf6a3b]`,`font-mono` |
|
||||
| 缩放 | `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 = [
|
||||
"base64 0.22.1",
|
||||
"notify",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -26,4 +26,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = ">=0.3.0, <0.3.47"
|
||||
notify = "8.2.0"
|
||||
regex = "1"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use base64::Engine;
|
||||
use regex::Regex;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct DirEntry {
|
||||
@@ -33,6 +34,23 @@ struct WorkspaceWatchErrorPayload {
|
||||
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 {
|
||||
let name = entry.file_name();
|
||||
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())
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
@@ -336,7 +510,8 @@ pub fn run() {
|
||||
create_file,
|
||||
delete_entry,
|
||||
rename_entry,
|
||||
save_media_file
|
||||
save_media_file,
|
||||
search_files
|
||||
])
|
||||
.setup(|app| {
|
||||
// ---- File menu ----
|
||||
@@ -400,9 +575,14 @@ pub fn run() {
|
||||
let toggle_outline = MenuItemBuilder::with_id("toggle_outline", "切换大纲")
|
||||
.accelerator("CmdOrCtrl+B")
|
||||
.build(app)?;
|
||||
let search = MenuItemBuilder::with_id("search", "搜索")
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
.build(app)?;
|
||||
|
||||
let view_menu = SubmenuBuilder::new(app, "视图")
|
||||
.item(&toggle_outline)
|
||||
.separator()
|
||||
.item(&search)
|
||||
.build()?;
|
||||
|
||||
// ---- Help menu ----
|
||||
@@ -453,6 +633,9 @@ pub fn run() {
|
||||
"about" => {
|
||||
let _ = app_handle.emit("show-about", ());
|
||||
}
|
||||
"search" => {
|
||||
let _ = app_handle.emit("menu-search", ());
|
||||
}
|
||||
_ => {
|
||||
// Forward other menu events to frontend
|
||||
let _ = app_handle.emit("menu-event", id);
|
||||
|
||||
16
src/App.vue
16
src/App.vue
@@ -1,5 +1,5 @@
|
||||
<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 {convertFileSrc, invoke, isTauri} from "@tauri-apps/api/core";
|
||||
import {getCurrentWindow} from "@tauri-apps/api/window";
|
||||
@@ -173,7 +173,8 @@ const appTheme = ref<AppTheme>(loadAppTheme());
|
||||
const showMoreMenu = ref(false);
|
||||
const settingsRequest = ref(0);
|
||||
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 isMarkdownDocument = computed(() => currentFileType.value === "markdown");
|
||||
const currentVideoMimeType = computed(() => {
|
||||
@@ -984,6 +985,15 @@ registerTauriListener("show-about", () => {
|
||||
registerTauriListener("menu-event", (event) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -992,6 +1002,7 @@ registerTauriListener("menu-event", (event) => {
|
||||
<div class="flex-1 flex overflow-hidden app-root">
|
||||
<!-- Left: Directory sidebar -->
|
||||
<DirectorySidebar
|
||||
ref="directorySidebarRef"
|
||||
:folder-path="folderPath"
|
||||
:entries="dirEntries"
|
||||
:current-file-path="currentFilePath"
|
||||
@@ -1009,6 +1020,7 @@ registerTauriListener("menu-event", (event) => {
|
||||
@create-file="onCreateFile"
|
||||
@delete-entry="onDeleteEntry"
|
||||
@rename-entry="onRenameEntry"
|
||||
@search-result-click="handleSearchResultClick"
|
||||
@update-default-document-mode="updateDefaultDocumentMode"
|
||||
@update-app-theme="updateAppTheme"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 TreeEntry from "./TreeEntry.vue";
|
||||
|
||||
@@ -19,6 +20,7 @@ interface WorkspaceEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
folderPath: string;
|
||||
@@ -52,24 +54,225 @@ const emit = defineEmits<{
|
||||
renameEntry: [path: string, newName: string];
|
||||
updateDefaultDocumentMode: [mode: DocumentMode];
|
||||
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 ----
|
||||
type ViewMode = "folder" | "search" | "setting";
|
||||
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 ----
|
||||
const MIN_SIDEBAR_WIDTH = 180;
|
||||
const MAX_SIDEBAR_WIDTH = 500;
|
||||
@@ -320,6 +523,8 @@ function openSettings(page: SettingsPage) {
|
||||
settingsPage.value = page;
|
||||
showSettings.value = true;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -364,11 +569,14 @@ function openSettings(page: SettingsPage) {
|
||||
<!-- ===== FOLDER VIEW ===== -->
|
||||
<div v-if="activeView === 'folder'" class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- File tree -->
|
||||
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
<!-- Workspace root as top-level tree node -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-2 py-2 flex flex-col gap-0.5"
|
||||
|
||||
>
|
||||
<TreeEntry
|
||||
v-if="rootEntry"
|
||||
:entry="rootEntry"
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
:entry="entry"
|
||||
:depth="0"
|
||||
:currentFilePath="currentFilePath"
|
||||
@toggle-folder="(path: string) => emit('toggleFolder', path)"
|
||||
@@ -398,20 +606,179 @@ function openSettings(page: SettingsPage) {
|
||||
|
||||
<!-- ===== SEARCH VIEW ===== -->
|
||||
<div v-if="activeView === 'search'" class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- 搜索输入区 -->
|
||||
<div class="px-3 py-3">
|
||||
<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>
|
||||
<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="搜索文件内容..."
|
||||
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 class="flex-1 flex items-center justify-center px-3 pb-8">
|
||||
<p class="sidebar-empty text-xs text-center leading-relaxed">
|
||||
搜索功能即将上线
|
||||
</p>
|
||||
|
||||
<!-- 搜索结果区域 -->
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-2">
|
||||
<!-- 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>
|
||||
|
||||
@@ -898,6 +1265,7 @@ function openSettings(page: SettingsPage) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.workspace-manager-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1283,4 +1651,61 @@ function openSettings(page: SettingsPage) {
|
||||
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>
|
||||
|
||||
@@ -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({
|
||||
flushPendingChanges,
|
||||
scrollToLine,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export default { name: "TreeEntry" };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||
import PromptDialog from "./PromptDialog.vue";
|
||||
import {
|
||||
@@ -23,6 +23,8 @@ interface DirEntry {
|
||||
children?: DirEntry[];
|
||||
}
|
||||
|
||||
const TREE_CONTEXT_MENU_CLOSE_EVENT = "yurou-tree-context-menu-close";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: DirEntry;
|
||||
depth: number;
|
||||
@@ -39,6 +41,44 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
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
|
||||
watch(
|
||||
@@ -109,18 +149,21 @@ function onPromptCancel() {
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────
|
||||
function handleCreateFolder() {
|
||||
closeContextMenu();
|
||||
openPrompt("新建文件夹", "", "输入文件夹名称", "", (name) => {
|
||||
emit("createFolder", props.entry.path + "/" + name);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateFile() {
|
||||
closeContextMenu();
|
||||
openPrompt("新建 MD 文件", "", "输入文件名称(无需 .md 后缀)", "", (name) => {
|
||||
emit("createFile", props.entry.path + "/" + name + ".md");
|
||||
});
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
closeContextMenu();
|
||||
openPrompt("重命名", "", "输入新名称", props.entry.name, (newName) => {
|
||||
if (newName !== props.entry.name) {
|
||||
emit("renameEntry", props.entry.path, newName);
|
||||
@@ -134,6 +177,7 @@ const confirmTitle = ref("");
|
||||
const confirmMessage = ref("");
|
||||
|
||||
function handleDelete() {
|
||||
closeContextMenu();
|
||||
confirmTitle.value = props.entry.is_dir ? "删除文件夹" : "删除文件";
|
||||
confirmMessage.value = props.entry.is_dir
|
||||
? `确定要永久删除文件夹「${props.entry.name}」及其所有内容吗?此操作不可撤销。`
|
||||
@@ -158,6 +202,14 @@ function onClick() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -184,12 +236,68 @@ function onClick() {
|
||||
@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">
|
||||
<!-- Folder row -->
|
||||
<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"
|
||||
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
||||
@click="onClick"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<i
|
||||
class="tree-arrow ri-arrow-right-s-line text-sm transition-transform shrink-0"
|
||||
@@ -253,6 +361,7 @@ function onClick() {
|
||||
|
||||
<!-- File -->
|
||||
<div
|
||||
data-tree-entry
|
||||
v-else
|
||||
:class="[
|
||||
'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` }"
|
||||
@click="onClick"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<i :class="['tree-entry-icon', fileIcon, 'text-sm shrink-0']"></i>
|
||||
<span class="truncate flex-1 min-w-0">{{ entry.name }}</span>
|
||||
@@ -349,4 +459,64 @@ function onClick() {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user