完善功能

This commit is contained in:
2026-07-10 16:07:53 +08:00
parent 0a3534a7e0
commit 78913c46b6
10 changed files with 971 additions and 31 deletions

View File

@@ -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
} }
} }

View File

@@ -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": "这个应用里有一个全局搜索的功能,帮我…"
} }

View File

@@ -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`.` 开头自动隐藏WindowsRust 端调用 `attrib +h` 设置隐藏属性 |
| 生命周期 | 随 md 文件创建/删除/重命名自动联动 |
| 引用方式 | markdown 中使用相对路径 `![alt](.doc/xxx.png)` |
### 整体流程
```
用户 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" │
│ │ }) │
│ └─ 光标处插入 ![alt](.doc/xxx.png) │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ 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
![原文件名](.doc/20260710_143025_a1b2.png)
```
多张图片粘贴时用换行分隔。视频文件使用相同的 `![alt](path)` 语法,由 `remarkLocalMedia` 插件根据扩展名自动转为 `<video>` 标签。

1
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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);

View File

@@ -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"
/> />

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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 class="flex-1 flex items-center justify-center px-3 pb-8"> </div>
<p class="sidebar-empty text-xs text-center leading-relaxed"> </div>
搜索功能即将上线
<!-- 搜索结果区域 -->
<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>
<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>

View File

@@ -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>

View File

@@ -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>