v0.2.0: 全面代码质量优化
- ESLint flat config + Prettier + EditorConfig - Markdown处理器LRU缓存 - Zustand选择器优化减少重渲染 - CodeMirror Compartment主题热切换 - Preview防抖(150ms) + IndexedDB去抖(500ms) - 组件拆分: App.tsx 310→104行, Sidebar.tsx 244→90行 - 统一错误处理 errorHandler.ts - ConfirmDialog替代原生confirm - LoadingSpinner加载状态 - Toast多条堆叠+类型区分 - 可访问性增强(ARIA属性、键盘导航) - Vitest测试框架(78个测试用例) - Git hooks(husky + lint-staged) - 项目文档(README.md, CONTRIBUTING.md)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -1,21 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true, node: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'lib/', 'assets/'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
node_modules/
|
||||
assets/
|
||||
lib/
|
||||
*.md
|
||||
package-lock.json
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。
|
||||
|
||||
## 开发环境
|
||||
|
||||
1. **前置要求**
|
||||
- Node.js >= 18.x
|
||||
- npm >= 9.x
|
||||
- Windows 10/11 x64
|
||||
|
||||
2. **克隆并安装**
|
||||
```bash
|
||||
git clone https://gitee.com/thzxx/MarkLite.git
|
||||
cd MarkLite
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **启动开发**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **TypeScript**: 严格模式,所有函数参数需显式类型标注
|
||||
- **ESLint**: 提交前确保 `npm run lint` 通过
|
||||
- **Prettier**: 提交前确保 `npm run format:check` 通过
|
||||
- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck
|
||||
|
||||
## 分支策略
|
||||
|
||||
- `main` — 稳定版本
|
||||
- `dev` — 开发分支
|
||||
- `feature/*` — 功能分支
|
||||
- `fix/*` — 修复分支
|
||||
|
||||
## 提交规范
|
||||
|
||||
使用语义化提交信息:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
类型(type):
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复 Bug
|
||||
- `docs`: 文档变更
|
||||
- `style`: 代码格式(不影响逻辑)
|
||||
- `refactor`: 重构
|
||||
- `perf`: 性能优化
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具链变更
|
||||
|
||||
示例:
|
||||
```
|
||||
feat(editor): 添加代码折叠功能
|
||||
fix(tab): 修复关闭标签后焦点丢失问题
|
||||
perf(markdown): 引入 processor LRU 缓存
|
||||
```
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
1. Fork 仓库并创建功能分支
|
||||
2. 确保所有检查通过:
|
||||
```bash
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript 类型检查
|
||||
npm run test # 单元测试
|
||||
```
|
||||
3. 编写清晰的 PR 描述,说明变更内容和原因
|
||||
4. 等待代码审查
|
||||
|
||||
## 添加测试
|
||||
|
||||
- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx`
|
||||
- 使用 Vitest + React Testing Library
|
||||
- 至少覆盖 stores、hooks、lib 目录下的核心模块
|
||||
|
||||
```bash
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# 监听模式
|
||||
npm run test:watch
|
||||
|
||||
# 覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main/ # 主进程 (Node.js)
|
||||
├── preload/ # 预加载脚本
|
||||
├── renderer/ # 渲染进程 (React)
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── lib/ # 工具库
|
||||
│ ├── db/ # IndexedDB 持久化
|
||||
│ ├── types/ # TypeScript 类型
|
||||
│ └── styles/ # 全局样式
|
||||
└── shared/ # 主进程/渲染进程共享
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"review_date": "2026-06-03",
|
||||
"reviewer": "Quality Reviewer Agent",
|
||||
"project": "MarkLite",
|
||||
"overall_score": 82,
|
||||
"verdict": "needs-fix",
|
||||
"score": {
|
||||
"code_quality": {
|
||||
"score": 65,
|
||||
"weight": 0.25,
|
||||
"details": {
|
||||
"typescript": {
|
||||
"status": "fail",
|
||||
"errors": 6,
|
||||
"issues": [
|
||||
"useFolderOperations.ts:20 - openFolderDialog() returns {canceled, filePaths} but code expects string",
|
||||
"useFolderOperations.ts:21 - Type mismatch: {canceled, filePaths} not assignable to string",
|
||||
"useFolderOperations.ts:24 - Same type mismatch in readDirTree call",
|
||||
"useFolderOperations.ts:27 - Same type mismatch",
|
||||
"types/ipc.ts:58 - Duplicate electronAPI declaration with conflicting types",
|
||||
"types/ipc.ts:58 - Subsequent property declarations must have same type"
|
||||
]
|
||||
},
|
||||
"eslint": {
|
||||
"status": "warn",
|
||||
"errors": 1,
|
||||
"warnings": 7,
|
||||
"issues": [
|
||||
"ERROR: tabStore.test.ts:55 - 'switchToTab' is assigned but never used",
|
||||
"WARN: 5x no-console (acceptable for Electron main/error handling)",
|
||||
"WARN: 3x react-hooks/exhaustive-deps (Editor.tsx, Preview.tsx)"
|
||||
]
|
||||
},
|
||||
"tests": {
|
||||
"status": "pass",
|
||||
"total_files": 5,
|
||||
"total_tests": 78,
|
||||
"all_passed": true,
|
||||
"duration_ms": 7960
|
||||
}
|
||||
}
|
||||
},
|
||||
"performance_optimization": {
|
||||
"score": 95,
|
||||
"weight": 0.20,
|
||||
"details": {
|
||||
"markdown_cache": {
|
||||
"status": "pass",
|
||||
"implementation": "LRU cache with MAX_CACHE_SIZE=10",
|
||||
"features": ["filePath-based cache key", "LRU eviction", "null key handling"]
|
||||
},
|
||||
"zustand_selectors": {
|
||||
"status": "pass",
|
||||
"implementation": "Fine-grained selectors throughout",
|
||||
"example": "useTabStore(s => s.tabs) instead of full store"
|
||||
},
|
||||
"codemirror_compartment": {
|
||||
"status": "pass",
|
||||
"implementation": "Compartment.reconfigure() for theme switching",
|
||||
"benefit": "No EditorView reconstruction on theme change"
|
||||
},
|
||||
"debounce": {
|
||||
"status": "pass",
|
||||
"implementations": [
|
||||
"Preview: 150ms debounce on markdown rendering",
|
||||
"TabStore: 500ms debounce on DB persistence"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"architecture_refactoring": {
|
||||
"score": 85,
|
||||
"weight": 0.20,
|
||||
"details": {
|
||||
"component_split": {
|
||||
"status": "pass",
|
||||
"extractions": [
|
||||
"FileTree extracted from Sidebar",
|
||||
"useFolderOperations hook from Sidebar",
|
||||
"EditorToolbar from Editor",
|
||||
"Toast with SingleToast sub-component"
|
||||
]
|
||||
},
|
||||
"type_safety": {
|
||||
"status": "warn",
|
||||
"issues": [
|
||||
"Duplicate ElectronAPI type definitions in preload.d.ts and types/ipc.ts",
|
||||
"openFolderDialog return type inconsistency"
|
||||
],
|
||||
"positives": [
|
||||
"Shared types properly defined",
|
||||
"IpcInvokeMap type mapping implemented",
|
||||
"Generic tryAsync pattern with proper typing"
|
||||
]
|
||||
},
|
||||
"error_handling": {
|
||||
"status": "pass",
|
||||
"implementation": [
|
||||
"AppError class with code and cause",
|
||||
"logError() for logging",
|
||||
"getErrorMessage() for user-facing messages",
|
||||
"tryAsync() wrapper returning [result, error] tuple",
|
||||
"ErrorBoundary for React component errors"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_experience": {
|
||||
"score": 90,
|
||||
"weight": 0.20,
|
||||
"details": {
|
||||
"confirm_dialog": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"Promise-based API via useConfirm hook",
|
||||
"Focus management (save/restore)",
|
||||
"ESC key handling",
|
||||
"Background scroll lock",
|
||||
"ARIA: role=alertdialog, aria-modal, aria-labelledby, aria-describedby",
|
||||
"Variant support (danger/warning/info)"
|
||||
]
|
||||
},
|
||||
"loading_states": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"LoadingSpinner with size variants (small/medium/large)",
|
||||
"Overlay mode support",
|
||||
"Global loading state via editorStore.loadingStates",
|
||||
"StatusBar integration with contextual labels",
|
||||
"ARIA: role=status, aria-label, aria-busy"
|
||||
]
|
||||
},
|
||||
"toast": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"Multi-toast stacking (max 5)",
|
||||
"Type differentiation with icons",
|
||||
"Auto-dismiss with configurable duration",
|
||||
"Enter/exit animations",
|
||||
"Close button",
|
||||
"ARIA: role=alert, aria-live=assertive"
|
||||
]
|
||||
},
|
||||
"accessibility": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"File tree: role=tree, role=treeitem, aria-expanded, aria-selected",
|
||||
"Keyboard navigation: Enter/Space on tree items",
|
||||
"Sidebar: aria-label on sections",
|
||||
"StatusBar: role=status, aria-live=polite",
|
||||
"Preview: aria-live=polite, aria-busy during render",
|
||||
"Resize handle: role=separator, aria-orientation, tabIndex"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"developer_experience": {
|
||||
"score": 88,
|
||||
"weight": 0.15,
|
||||
"details": {
|
||||
"test_framework": {
|
||||
"status": "pass",
|
||||
"setup": {
|
||||
"framework": "Vitest 4.1.8",
|
||||
"environment": "jsdom",
|
||||
"setup_file": "@testing-library/jest-dom/vitest",
|
||||
"coverage_provider": "v8",
|
||||
"coverage_targets": ["stores", "lib", "hooks"]
|
||||
},
|
||||
"test_files": [
|
||||
"tabStore.test.ts (17 tests)",
|
||||
"editorStore.test.ts (12 tests)",
|
||||
"fileUtils.test.ts (21 tests)",
|
||||
"markdown.test.ts (16 tests)",
|
||||
"errorHandler.test.ts (12 tests)"
|
||||
]
|
||||
},
|
||||
"git_hooks": {
|
||||
"status": "pass",
|
||||
"setup": {
|
||||
"tool": "Husky 9.1.7",
|
||||
"pre_commit": "lint-staged",
|
||||
"lint_staged": {
|
||||
"ts_tsx": ["eslint --fix", "tsc --noEmit --pretty"],
|
||||
"json_css_md": ["prettier --write"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentation": {
|
||||
"status": "pass",
|
||||
"files": ["CONTRIBUTING.md", "README.md", "DESIGN.md", "DEVSETUP.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "typescript",
|
||||
"file": "src/renderer/hooks/useFolderOperations.ts",
|
||||
"lines": [20, 21, 24, 27],
|
||||
"description": "openFolderDialog() returns {canceled: boolean, filePaths: string[]} but useFolderOperations treats return as string. Need to destructure result like: const result = await window.electronAPI.openFolderDialog(); if (!result.canceled && result.filePaths[0]) { ... }",
|
||||
"fix": "Update useFolderOperations to handle OpenDialogReturnValue type"
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "typescript",
|
||||
"file": "src/renderer/types/ipc.ts",
|
||||
"lines": [54, 58],
|
||||
"description": "Duplicate Window.electronAPI declaration: preload.d.ts declares as ElectronAPI (required), types/ipc.ts declares as ElectronAPI | undefined (optional). These conflict.",
|
||||
"fix": "Remove duplicate declaration from ipc.ts, keep preload.d.ts as source of truth"
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"category": "eslint",
|
||||
"file": "src/renderer/stores/__tests__/tabStore.test.ts",
|
||||
"lines": [55],
|
||||
"description": "'switchToTab' is destructured but never used in test",
|
||||
"fix": "Remove unused destructuring or use the variable"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "react-hooks",
|
||||
"file": "src/renderer/components/Editor/Editor.tsx",
|
||||
"lines": [43, 55],
|
||||
"description": "useEffect hooks have missing dependencies (activeTab, setContent, setScrollTop, setSelection, getScrollTop, getSelection, updateTabScroll). Intentionally using activeTabId as trigger to avoid infinite loops.",
|
||||
"fix": "Add eslint-disable comment with explanation, or refactor to use refs"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "react-hooks",
|
||||
"file": "src/renderer/components/Preview/Preview.tsx",
|
||||
"lines": [53],
|
||||
"description": "useEffect missing 'activeTab' dependency. Using activeTab?.content and activeTab?.filePath instead.",
|
||||
"fix": "Already correctly using optional chaining in deps array"
|
||||
}
|
||||
],
|
||||
"strengths": [
|
||||
"Excellent LRU cache implementation for Markdown processor with proper eviction strategy",
|
||||
"CodeMirror Compartment pattern for efficient theme switching without EditorView reconstruction",
|
||||
"Comprehensive Zustand selector usage preventing unnecessary re-renders",
|
||||
"Well-designed ConfirmDialog with full accessibility support (focus trap, ESC, ARIA)",
|
||||
"LoadingSpinner component with multiple sizes, overlay mode, and proper ARIA attributes",
|
||||
"Toast system supports stacking, types, animations, and accessibility",
|
||||
"Unified error handling with AppError, logError, getErrorMessage, tryAsync patterns",
|
||||
"Good component extraction (FileTree, EditorToolbar, useFolderOperations)",
|
||||
"78 unit tests covering core modules with proper mocking",
|
||||
"Debounce implementation for both preview rendering and DB persistence",
|
||||
"Git hooks with lint-staged for automated code quality checks",
|
||||
"Comprehensive documentation (CONTRIBUTING.md, DESIGN.md, README.md)"
|
||||
],
|
||||
"summary": "MarkLite optimization project demonstrates solid engineering across 5 phases. Performance optimizations (LRU cache, Compartment, Zustand selectors, debounce) are well-implemented. UX improvements (ConfirmDialog, Loading, Toast, accessibility) are comprehensive with proper ARIA support. Architecture refactoring (component split, error handling, type safety) shows good design patterns. However, there are 2 critical TypeScript errors that must be fixed before the project can compile: (1) type mismatch in useFolderOperations.ts where openFolderDialog() return type is not properly handled, and (2) duplicate/conflicting electronAPI type declarations. There is also 1 ESLint error (unused variable in test). All 78 tests pass. The project is close to production-ready but needs the TypeScript errors resolved first."
|
||||
}
|
||||
@@ -85,8 +85,39 @@ npm run typecheck
|
||||
|
||||
# ESLint 代码检查
|
||||
npm run lint
|
||||
|
||||
# 运行单元测试
|
||||
npm run test
|
||||
|
||||
# 监听模式运行测试
|
||||
npm run test:watch
|
||||
|
||||
# 生成测试覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
项目使用 **Vitest** + **React Testing Library** 作为测试框架。
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
npm run test
|
||||
|
||||
# 监听模式(开发时持续运行)
|
||||
npm run test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
测试覆盖以下核心模块:
|
||||
- `stores/` — Zustand 状态管理(tabStore, editorStore)
|
||||
- `lib/` — 工具库(fileUtils, markdown, errorHandler)
|
||||
- `hooks/` — 自定义 Hooks
|
||||
|
||||
测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`。
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
@@ -131,7 +162,9 @@ MarkLite/
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── tsconfig.node.json # Node 端 TypeScript 配置
|
||||
├── electron.vite.config.ts # electron-vite 构建配置
|
||||
├── vitest.config.ts # Vitest 测试框架配置
|
||||
├── .eslintrc.cjs # ESLint + TypeScript 规则
|
||||
├── CONTRIBUTING.md # 贡献指南
|
||||
│
|
||||
├── src/
|
||||
│ ├── main/ # 主进程 (Node.js)
|
||||
@@ -216,6 +249,17 @@ MarkLite/
|
||||
- ✅ HTML 内联元素
|
||||
- ✅ GFM(GitHub Flavored Markdown)
|
||||
|
||||
## 🔧 Git Hooks
|
||||
|
||||
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
|
||||
|
||||
- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
|
||||
|
||||
```bash
|
||||
# 初始化 git hooks(npm install 时自动执行)
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx)
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
{
|
||||
"planVersion": "1.0.0",
|
||||
"generatedDate": "2026-06-03",
|
||||
"project": "MarkLite",
|
||||
"basedOnRequirements": "docs/optimization-requirements.json",
|
||||
|
||||
"optimization_phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "基础设施与代码规范",
|
||||
"priority": "P0-CRITICAL",
|
||||
"estimatedEffort": "1-2 天",
|
||||
"description": "建立代码质量基线:ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复。这些是后续所有优化的前提。",
|
||||
"items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
|
||||
"rationale": "CQ-01 (ESLint) 是所有代码质量改进的基础;AR-05 (ErrorBoundary) 保护后续重构不会导致白屏;DX-07 (路径别名) 修复后简化后续 import。",
|
||||
"exitCriteria": [
|
||||
"npm run lint 通过且返回 0",
|
||||
"npm run format 能格式化全部源文件",
|
||||
"ErrorBoundary 包裹 App 组件",
|
||||
"TypeScript 路径别名在 vite config 中完整映射"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"name": "关键性能修复",
|
||||
"priority": "P0-CRITICAL",
|
||||
"estimatedEffort": "1-2 天",
|
||||
"description": "修复影响日常使用的核心性能瓶颈:Markdown 处理器缓存、Zustand 选择器重渲染、CodeMirror Compartment 主题切换、Preview 防抖、IndexedDB 去抖。",
|
||||
"items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
|
||||
"rationale": "PF-01 和 PF-02 是 P0-CRITICAL 级性能问题,直接影响用户感知;PF-04 的 Compartment 重构是 PF-03 (React.memo) 的前置;PF-07/08 是低风险高收益的防抖优化。",
|
||||
"exitCriteria": [
|
||||
"Markdown processor 实例复用率 > 90%",
|
||||
"编辑时仅 activeTab 相关组件重渲染(React DevTools Profiler 验证)",
|
||||
"darkMode 切换不销毁 EditorView",
|
||||
"Preview 渲染频率 < 10 次/秒(快速输入场景)",
|
||||
"IndexedDB 写入频率 < 3 次/秒(连续编辑场景)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"name": "架构重构与代码质量",
|
||||
"priority": "P1-HIGH",
|
||||
"estimatedEffort": "2-3 天",
|
||||
"description": "组件拆分、TypeScript 类型完善、错误处理统一、消除架构反模式(DOM CustomEvent、重复设置加载)。",
|
||||
"items": ["AR-01", "AR-02", "AR-03", "AR-04", "CQ-04", "CQ-05", "PF-03"],
|
||||
"rationale": "组件拆分 (AR-01/02) 和 React.memo (PF-03) 依赖 Phase 1 的 ESLint 和 Phase 2 的 Zustand 优化;类型完善 (CQ-04) 和错误处理 (CQ-05) 可与架构重构并行。",
|
||||
"exitCriteria": [
|
||||
"App.tsx < 150 行",
|
||||
"Sidebar.tsx < 120 行",
|
||||
"所有函数参数有显式类型标注",
|
||||
"无空 catch 块,所有错误有具体描述",
|
||||
"设置加载只触发一次 IndexedDB 读取"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"name": "用户体验改进",
|
||||
"priority": "P1-HIGH",
|
||||
"estimatedEffort": "2-3 天",
|
||||
"description": "替换原生 confirm()、添加加载状态指示、改进 Toast 通知系统、增强可访问性。",
|
||||
"items": ["UX-01", "UX-02", "UX-05", "UX-07"],
|
||||
"rationale": "UX 改进依赖 Phase 3 的组件拆分(新的 ConfirmDialog、Toast 组件需要独立目录)和 ErrorBoundary(统一错误处理模式)。",
|
||||
"exitCriteria": [
|
||||
"零处原生 confirm() 调用",
|
||||
"文件打开、目录加载、Markdown 渲染有 loading 状态",
|
||||
"Toast 支持多条堆叠 + 类型区分 (success/error/warning)",
|
||||
"所有交互元素有 ARIA 属性"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"name": "开发体验与测试",
|
||||
"priority": "P2-MEDIUM",
|
||||
"estimatedEffort": "2-3 天",
|
||||
"description": "建立单元测试框架、强化类型安全、添加项目文档和 Git hooks。",
|
||||
"items": ["DX-01", "DX-02", "DX-04", "DX-05"],
|
||||
"rationale": "测试框架应在代码稳定后添加(Phase 1-4 的重构会导致测试频繁失效);DX-02 (preload 类型安全) 依赖 CQ-04 (类型完善)。",
|
||||
"exitCriteria": [
|
||||
"Vitest + React Testing Library 可运行",
|
||||
"核心模块测试覆盖 > 60%",
|
||||
"README.md 包含完整开发指南",
|
||||
"pre-commit 钩子执行 ESLint + typecheck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"name": "高级功能与安全加固",
|
||||
"priority": "P2-P3",
|
||||
"estimatedEffort": "3-5 天",
|
||||
"description": "代码分割、分屏模式、拖拽排序、文件搜索、安全加固 (CSP/file:// 协议)、CI/CD 配置。",
|
||||
"items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
|
||||
"rationale": "这些是「锦上添花」的优化,不阻塞核心功能。代码分割 (PF-06) 和分屏模式 (UX-03) 是大规模功能,需要在架构稳定后实施。",
|
||||
"exitCriteria": [
|
||||
"renderer bundle 拆分为 3+ 个 chunk",
|
||||
"分屏模式可正常编辑+预览",
|
||||
"CSP header 配置且预览正常渲染",
|
||||
"GitHub Actions CI 流水线 lint → typecheck → test → build"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"file_manifest": {
|
||||
"new_files": [
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"phase": 1,
|
||||
"item": "CQ-01",
|
||||
"description": "ESLint v9 flat config 格式配置文件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": ".prettierrc",
|
||||
"phase": 1,
|
||||
"item": "CQ-02",
|
||||
"description": "Prettier 代码格式化配置",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": ".prettierignore",
|
||||
"phase": 1,
|
||||
"item": "CQ-02",
|
||||
"description": "Prettier 忽略文件列表",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": ".editorconfig",
|
||||
"phase": 1,
|
||||
"item": "CQ-03",
|
||||
"description": "编辑器统一行为配置",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
|
||||
"phase": 1,
|
||||
"item": "AR-05",
|
||||
"description": "React ErrorBoundary 组件,显示错误信息和重启按钮",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ErrorBoundary/index.ts",
|
||||
"phase": 1,
|
||||
"item": "AR-05",
|
||||
"description": "ErrorBoundary barrel export",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/AboutDialog/AboutDialog.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-01",
|
||||
"description": "从 App.tsx 提取的 About 对话框组件",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/AboutDialog/index.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-01",
|
||||
"description": "AboutDialog barrel export",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useFileOperations.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-01",
|
||||
"description": "从 App.tsx 提取的文件操作 hook(open/save/saveAs/openRecent)",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Sidebar/FileTree.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-02",
|
||||
"description": "从 Sidebar.tsx 提取的递归文件树组件",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useSidebarResize.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-02",
|
||||
"description": "从 Sidebar.tsx 提取的 resize 逻辑 hook",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ConfirmDialog/ConfirmDialog.tsx",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"description": "自定义确认对话框组件,替代原生 confirm()",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ConfirmDialog/index.ts",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"description": "ConfirmDialog barrel export",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useConfirm.ts",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"description": "确认对话框状态管理 hook",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/LoadingSpinner/LoadingSpinner.tsx",
|
||||
"phase": 4,
|
||||
"item": "UX-02",
|
||||
"description": "通用加载指示器组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/toolbar-icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "工具栏相关 SVG 图标组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/sidebar-icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "侧边栏相关 SVG 图标组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/app-icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "应用级 SVG 图标组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/index.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "Icons barrel export(保持向后兼容)",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/markdown-processor.ts",
|
||||
"phase": 2,
|
||||
"item": "PF-01",
|
||||
"description": "Markdown processor 缓存管理器",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useSettingsInit.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-03",
|
||||
"description": "统一设置加载 hook,一次 IndexedDB 读取分发到 store",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "Vitest 测试框架配置",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/stores/__tests__/tabStore.test.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "tabStore 单元测试",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/__tests__/fileUtils.test.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "fileUtils 单元测试",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/__tests__/markdown.test.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "Markdown 渲染单元测试",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"phase": 5,
|
||||
"item": "DX-04",
|
||||
"description": "项目介绍、安装、开发、构建说明",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "CONTRIBUTING.md",
|
||||
"phase": 5,
|
||||
"item": "DX-04",
|
||||
"description": "贡献指南、代码规范、PR 流程",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": ".github/workflows/ci.yml",
|
||||
"phase": 6,
|
||||
"item": "DX-06",
|
||||
"description": "GitHub Actions CI 配置",
|
||||
"size_estimate": "small"
|
||||
}
|
||||
],
|
||||
"modified_files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"phase": [1, 2, 5],
|
||||
"item": ["CQ-02", "DX-05", "DX-01"],
|
||||
"changes": [
|
||||
"添加 prettier、eslint-config-prettier 依赖",
|
||||
"添加 vitest、@testing-library/react、jsdom 依赖",
|
||||
"添加 husky、lint-staged 依赖",
|
||||
"添加 test、test:watch、test:coverage、format 脚本"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "electron.vite.config.ts",
|
||||
"phase": [1, 6],
|
||||
"item": ["DX-07", "PF-06"],
|
||||
"changes": [
|
||||
"renderer.resolve.alias 添加 @main 映射",
|
||||
"renderer.build.rollupOptions.output 添加 manualChunks 分割策略"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/main.tsx",
|
||||
"phase": 1,
|
||||
"item": "AR-05",
|
||||
"changes": [
|
||||
"用 ErrorBoundary 包裹 App 组件"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/App.tsx",
|
||||
"phase": [1, 3],
|
||||
"item": ["AR-01", "AR-05", "CQ-05"],
|
||||
"changes": [
|
||||
"提取 About 对话框到 AboutDialog 组件",
|
||||
"提取文件操作逻辑到 useFileOperations hook",
|
||||
"统一错误处理模式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/markdown.ts",
|
||||
"phase": 2,
|
||||
"item": "PF-01",
|
||||
"changes": [
|
||||
"引入 markdown-processor.ts 缓存管理器",
|
||||
"renderMarkdown 改为使用缓存的 processor 实例",
|
||||
"添加 processor 缓存失效策略(仅 filePath 变化时重建)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/stores/tabStore.ts",
|
||||
"phase": [2, 3],
|
||||
"item": ["PF-02", "PF-08", "AR-07"],
|
||||
"changes": [
|
||||
"添加 getActiveTab() 选择器方法(返回 activeTab 对象引用)",
|
||||
"添加 getActiveTabId() 选择器方法",
|
||||
"将 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)",
|
||||
"统一异步保存模式为 Zustand subscribeWithSelector middleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Editor/Editor.tsx",
|
||||
"phase": 2,
|
||||
"item": "PF-02",
|
||||
"changes": [
|
||||
"替换 const tabs = useTabStore(s => s.tabs) 为 const activeTab = useTabStore(s => s.getActiveTab())",
|
||||
"消除 tabs.find() 调用"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Preview/Preview.tsx",
|
||||
"phase": [2],
|
||||
"item": ["PF-02", "PF-07"],
|
||||
"changes": [
|
||||
"替换 tabs 订阅为 activeTab 选择器",
|
||||
"添加 150ms 防抖(useDebounce hook)",
|
||||
"使用新的 markdown processor 缓存"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"phase": 2,
|
||||
"item": "PF-02",
|
||||
"changes": [
|
||||
"替换 tabs 订阅为 activeTab 选择器"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Sidebar/Sidebar.tsx",
|
||||
"phase": [2, 3],
|
||||
"item": ["PF-02", "AR-02"],
|
||||
"changes": [
|
||||
"替换 tabs 订阅为 activeTab 选择器",
|
||||
"提取 FileTree 组件到独立文件",
|
||||
"提取 resize 逻辑到 useSidebarResize hook"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"phase": 2,
|
||||
"item": "PF-04",
|
||||
"changes": [
|
||||
"引入 Compartment API(@codemirror/state)",
|
||||
"theme 扩展放入 Compartment",
|
||||
"darkMode 变化时通过 compartment.reconfigure() 切换主题",
|
||||
"移除 EditorView 销毁重建逻辑"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Toolbar/Toolbar.tsx",
|
||||
"phase": 3,
|
||||
"item": "PF-03",
|
||||
"changes": [
|
||||
"用 React.memo 包装组件导出"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/TabBar/TabBar.tsx",
|
||||
"phase": [3, 4],
|
||||
"item": ["PF-03", "UX-01"],
|
||||
"changes": [
|
||||
"用 React.memo 包装组件导出",
|
||||
"替换 4 处 confirm() 为自定义 ConfirmDialog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useUnsavedWarning.ts",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"changes": [
|
||||
"替换 confirm() 为 electron.dialog.showMessageBox 或自定义 ConfirmDialog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useTheme.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-03",
|
||||
"changes": [
|
||||
"移除独立的 settingsRepository.load() 调用",
|
||||
"改为从统一的 useSettingsInit hook 获取设置"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useSettings.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-03",
|
||||
"changes": [
|
||||
"移除独立的 settingsRepository.load() 调用",
|
||||
"改为从统一的 useSettingsInit hook 获取设置"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useFileWatch.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-04",
|
||||
"changes": [
|
||||
"移除 window.dispatchEvent(CustomEvent) 调用",
|
||||
"改为调用 Zustand store action 更新文件状态"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"changes": [
|
||||
"拆分为 icons/toolbar-icons.tsx、icons/sidebar-icons.tsx、icons/app-icons.tsx",
|
||||
"原文件改为 re-export barrel 文件(向后兼容)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Toast/Toast.tsx",
|
||||
"phase": 4,
|
||||
"item": "UX-05",
|
||||
"changes": [
|
||||
"支持多条堆叠 Toast",
|
||||
"添加 success/error/warning 类型",
|
||||
"添加手动关闭按钮"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/styles/global.css",
|
||||
"phase": 4,
|
||||
"item": ["UX-01", "UX-02", "UX-05"],
|
||||
"changes": [
|
||||
"添加 ConfirmDialog 样式",
|
||||
"添加 LoadingSpinner 样式",
|
||||
"添加 Toast 堆叠和类型样式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/preload/index.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-02",
|
||||
"changes": [
|
||||
"添加 ElectronAPI 类型约束: const api: ElectronAPI = { ... }",
|
||||
"确保 contextBridge.exposeInMainWorld 返回值类型匹配"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/types/settings.ts",
|
||||
"phase": 6,
|
||||
"item": "UX-03",
|
||||
"changes": [
|
||||
"ViewMode 类型扩展为 'editor' | 'preview' | 'split'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/index.html",
|
||||
"phase": 6,
|
||||
"item": "SE-01",
|
||||
"changes": [
|
||||
"添加 CSP meta tag 或在 main process BrowserWindow 中配置 CSP header"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/window-manager.ts",
|
||||
"phase": 6,
|
||||
"item": "SE-01",
|
||||
"changes": [
|
||||
"在 BrowserWindow webPreferences 中配置 CSP header",
|
||||
"确认 nodeIntegration=false, contextIsolation=true, sandbox=true"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/markdown.ts",
|
||||
"phase": 6,
|
||||
"item": "SE-02",
|
||||
"changes": [
|
||||
"rehypeFixImages 中的路径校验从简单 '..' 检查改为 path.resolve + allowlist 检查"
|
||||
]
|
||||
}
|
||||
],
|
||||
"deleted_files": []
|
||||
},
|
||||
|
||||
"technical_solutions": {
|
||||
"CQ-01_eslint_config": {
|
||||
"id": "CQ-01",
|
||||
"title": "ESLint v9 Flat Config",
|
||||
"solution": "创建 eslint.config.js,使用 ESM flat config 格式。",
|
||||
"implementation": {
|
||||
"file": "eslint.config.js",
|
||||
"format": "ESM (export default [...])",
|
||||
"config_content": {
|
||||
"imports": [
|
||||
"eslint-plugin-react-hooks",
|
||||
"eslint-plugin-react-refresh",
|
||||
"@typescript-eslint/eslint-plugin"
|
||||
],
|
||||
"ignores": ["dist/**", "node_modules/**", "out/**"],
|
||||
"languageOptions": {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": { "jsx": true }
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"no-console": "warn",
|
||||
"prefer-const": "error",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
},
|
||||
"notes": "ESLint v9 不再支持 --ext 参数,flat config 中通过 files glob 控制。需更新 package.json 的 lint 脚本。"
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW"
|
||||
},
|
||||
|
||||
"PF-01_markdown_cache": {
|
||||
"id": "PF-01",
|
||||
"title": "Markdown Processor 缓存",
|
||||
"solution": "将 unified processor 实例提取到独立模块,使用 Map 按 filePath 缓存。",
|
||||
"implementation": {
|
||||
"new_file": "src/renderer/lib/markdown-processor.ts",
|
||||
"approach": "Module-level singleton cache",
|
||||
"cache_strategy": {
|
||||
"key": "filePath (string)",
|
||||
"value": "Processor instance (unified processor with all 7 plugins)",
|
||||
"invalidation": "filePath 变化时创建新 processor,旧实例保留在 cache 中供回退",
|
||||
"max_cache_size": 10,
|
||||
"eviction": "LRU - 超过 max_cache_size 时移除最久未使用的"
|
||||
},
|
||||
"code_sketch": "const processorCache = new Map<string, Processor>();\nexport function getProcessor(filePath: string): Processor {\n if (processorCache.has(filePath)) return processorCache.get(filePath)!;\n const p = unified().use(remarkParse)...;\n processorCache.set(filePath, p);\n return p;\n}"
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW - unified processor 是无状态的,缓存安全"
|
||||
},
|
||||
|
||||
"PF-02_zustand_selectors": {
|
||||
"id": "PF-02",
|
||||
"title": "Zustand 选择器优化",
|
||||
"solution": "在 tabStore 中添加 getActiveTab 派生选择器,各组件只订阅 activeTab 引用。",
|
||||
"implementation": {
|
||||
"approach": "在 store 中添加 getter 方法 + useShallow",
|
||||
"store_changes": {
|
||||
"new_methods": [
|
||||
"getActiveTab(): Tab | undefined — 从 state.tabs 和 state.activeTabId 派生",
|
||||
"getActiveTabId(): string | null — 直接返回 state.activeTabId"
|
||||
],
|
||||
"notes": "Zustand 5 中 useShallow 来自 'zustand/react/shallow'"
|
||||
},
|
||||
"component_changes": {
|
||||
"before": "const tabs = useTabStore(s => s.tabs);\nconst activeTab = tabs.find(t => t.id === activeTabId);",
|
||||
"after": "const activeTab = useTabStore(s => s.tabs.find(t => t.id === s.activeTabId));",
|
||||
"note": "直接在选择器中 find,当 activeTab 的引用未变时不会触发重渲染。或使用 useShallow + getActiveTab 方法。"
|
||||
},
|
||||
"affected_files": [
|
||||
"src/renderer/components/Editor/Editor.tsx",
|
||||
"src/renderer/components/Preview/Preview.tsx",
|
||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"src/renderer/components/Sidebar/Sidebar.tsx"
|
||||
]
|
||||
},
|
||||
"dependencies": ["PF-01"],
|
||||
"risk": "MEDIUM - 需验证所有订阅点,遗漏会导致功能异常"
|
||||
},
|
||||
|
||||
"PF-04_codemirror_compartment": {
|
||||
"id": "PF-04",
|
||||
"title": "CodeMirror Compartment 主题切换",
|
||||
"solution": "使用 @codemirror/state 的 Compartment API 封装主题扩展,darkMode 变化时 reconfigure 而非销毁重建。",
|
||||
"implementation": {
|
||||
"file": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"approach": "Compartment + reconfigure",
|
||||
"code_sketch": "import { Compartment } from '@codemirror/state';\nconst themeCompartment = new Compartment();\n\n// 创建时:\nconst extensions = [\n // ... other extensions\n themeCompartment.of(darkMode ? oneDark : [])\n];\n\n// darkMode 变化时:\nuseEffect(() => {\n if (editorView) {\n editorView.dispatch({\n effects: themeCompartment.reconfigure(darkMode ? oneDark : [])\n });\n }\n}, [darkMode]);",
|
||||
"key_changes": [
|
||||
"移除 useEffect 中 EditorView 销毁重建逻辑",
|
||||
"theme 扩展改为 Compartment.of() 包装",
|
||||
"darkMode 变化改为 dispatch reconfigure effect",
|
||||
"保留其他扩展不变(keymap、language 等)"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "HIGH - 影响编辑器核心初始化流程,需仔细测试切换前后编辑器状态保留"
|
||||
},
|
||||
|
||||
"PF-07_preview_debounce": {
|
||||
"id": "PF-07",
|
||||
"title": "Preview 渲染防抖",
|
||||
"solution": "创建 useDebounce hook,在 Preview.tsx 中对 content 变化进行 150ms 防抖。",
|
||||
"implementation": {
|
||||
"new_file": "src/renderer/hooks/useDebounce.ts",
|
||||
"approach": "标准 useDebounce hook + useEffect",
|
||||
"code_sketch": "function useDebounce<T>(value: T, delay: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedValue(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debouncedValue;\n}",
|
||||
"usage_in_preview": "const content = activeTab?.content ?? '';\nconst debouncedContent = useDebounce(content, 150);\nuseEffect(() => {\n // renderMarkdown(debouncedContent)\n}, [debouncedContent]);"
|
||||
},
|
||||
"dependencies": ["PF-01", "PF-02"],
|
||||
"risk": "LOW"
|
||||
},
|
||||
|
||||
"PF-08_indexeddb_debounce": {
|
||||
"id": "PF-08",
|
||||
"title": "IndexedDB 保存去抖",
|
||||
"solution": "将 tabStore 中的 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)。",
|
||||
"implementation": {
|
||||
"file": "src/renderer/stores/tabStore.ts",
|
||||
"approach": "引入 debounce 工具函数",
|
||||
"code_sketch": "// utils/debounce.ts\nexport function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {\n let timer: ReturnType<typeof setTimeout>;\n return ((...args: any[]) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), ms);\n }) as T;\n}\n\n// tabStore.ts\nconst debouncedSaveToDB = debounce(() => saveToDB(), 500);\nconst debouncedSaveActiveTab = debounce((id: string) => tabRepository.saveActiveTabId(id), 300);",
|
||||
"affected_patterns": [
|
||||
"setTimeout(() => saveToDB(), 0) → debouncedSaveToDB()",
|
||||
"setTimeout(() => tabRepository.saveActiveTabId(activeTabId), 0) → debouncedSaveActiveTab(activeTabId)"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW - 去抖延迟可配置,用户关闭窗口前需 flush"
|
||||
},
|
||||
|
||||
"AR-05_error_boundary": {
|
||||
"id": "AR-05",
|
||||
"title": "React ErrorBoundary",
|
||||
"solution": "创建 class component ErrorBoundary,捕获渲染错误,显示友好错误页面。",
|
||||
"implementation": {
|
||||
"file": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
|
||||
"features": [
|
||||
"componentDidCatch + getDerivedStateFromError",
|
||||
"显示错误信息、错误栈(开发模式)",
|
||||
"「重新加载」按钮调用 location.reload() 或 window.electronAPI 重启",
|
||||
"「复制错误信息」按钮",
|
||||
"支持 fallback 自定义 UI"
|
||||
],
|
||||
"integration": "在 main.tsx 中 <ErrorBoundary><App /></ErrorBoundary>"
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW"
|
||||
},
|
||||
|
||||
"AR-01_app_split": {
|
||||
"id": "AR-01",
|
||||
"title": "App.tsx 拆分",
|
||||
"solution": "提取 AboutDialog 组件和 useFileOperations hook。",
|
||||
"implementation": {
|
||||
"extractions": [
|
||||
{
|
||||
"name": "AboutDialog",
|
||||
"source": "App.tsx 内联 JSX (~40 行)",
|
||||
"target": "src/renderer/components/AboutDialog/AboutDialog.tsx",
|
||||
"props": "{ visible: boolean; onClose: () => void; version: string }"
|
||||
},
|
||||
{
|
||||
"name": "useFileOperations",
|
||||
"source": "App.tsx 中的 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 函数",
|
||||
"target": "src/renderer/hooks/useFileOperations.ts",
|
||||
"returns": "{ handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }"
|
||||
}
|
||||
],
|
||||
"expected_result": "App.tsx 从 ~307 行减少到 ~120 行"
|
||||
},
|
||||
"dependencies": ["CQ-01", "AR-05"],
|
||||
"risk": "LOW - 提取式重构,行为不变"
|
||||
},
|
||||
|
||||
"AR-03_unified_settings": {
|
||||
"id": "AR-03",
|
||||
"title": "统一设置加载",
|
||||
"solution": "创建 useSettingsInit hook,一次性从 IndexedDB 加载所有设置,分发到各 store。",
|
||||
"implementation": {
|
||||
"new_file": "src/renderer/hooks/useSettingsInit.ts",
|
||||
"approach": "单次加载 + Zustand setState 分发",
|
||||
"code_sketch": "export function useSettingsInit() {\n const [loaded, setLoaded] = useState(false);\n useEffect(() => {\n settingsRepository.load().then(settings => {\n useThemeStore.getState().applySettings(settings);\n useEditorStore.getState().applySettings(settings);\n setLoaded(true);\n });\n }, []);\n return loaded;\n}",
|
||||
"affected_files": [
|
||||
"src/renderer/hooks/useTheme.ts — 移除独立 load()",
|
||||
"src/renderer/hooks/useSettings.ts — 移除独立 load()"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "MEDIUM - 设置加载时序变化可能影响首次渲染"
|
||||
},
|
||||
|
||||
"UX-01_confirm_dialog": {
|
||||
"id": "UX-01",
|
||||
"title": "自定义确认对话框",
|
||||
"solution": "创建 ConfirmDialog 组件 + useConfirm hook,替代原生 confirm()。",
|
||||
"implementation": {
|
||||
"approach": "Promise-based confirm API",
|
||||
"code_sketch": "// useConfirm.ts\nexport function useConfirm() {\n const [state, setState] = useState<{ message: string; resolve: (v: boolean) => void } | null>(null);\n const confirm = (message: string) => new Promise<boolean>(resolve => setState({ message, resolve }));\n const handleConfirm = () => { state?.resolve(true); setState(null); };\n const handleCancel = () => { state?.resolve(false); setState(null); };\n return { confirm, dialog: state ? <ConfirmDialog .../> : null };\n}",
|
||||
"affected_locations": [
|
||||
"TabBar.tsx — 4 处 confirm() 调用",
|
||||
"useUnsavedWarning.ts — 1 处 confirm() 调用"
|
||||
]
|
||||
},
|
||||
"dependencies": ["AR-01"],
|
||||
"risk": "MEDIUM - Promise-based API 与原生 confirm() 的同步行为不同,需确保调用方正确 await"
|
||||
},
|
||||
|
||||
"UX-03_split_mode": {
|
||||
"id": "UX-03",
|
||||
"title": "分屏模式",
|
||||
"solution": "扩展 ViewMode 为 'split',布局组件根据 mode 同时渲染 Editor 和 Preview。",
|
||||
"implementation": {
|
||||
"approach": "CSS Grid/ResizeObserver 双栏布局",
|
||||
"key_changes": [
|
||||
"types/settings.ts: ViewMode = 'editor' | 'preview' | 'split'",
|
||||
"App.tsx 或新 Layout 组件: 根据 viewMode 条件渲染",
|
||||
"split 模式: 左 50% Editor + 右 50% Preview,可拖拽调整比例",
|
||||
"添加 Ctrl+3 快捷键切换到 split 模式",
|
||||
"可选:编辑器滚动同步预览滚动位置"
|
||||
]
|
||||
},
|
||||
"dependencies": ["PF-01", "PF-02", "PF-04"],
|
||||
"risk": "HIGH - 需要同时维护两个视图实例,内存和性能需关注"
|
||||
},
|
||||
|
||||
"PF-06_code_splitting": {
|
||||
"id": "PF-06",
|
||||
"title": "代码分割",
|
||||
"solution": "在 electron.vite.config.ts 中配置 Rollup manualChunks,按依赖类型分割。",
|
||||
"implementation": {
|
||||
"file": "electron.vite.config.ts",
|
||||
"approach": "manualChunks + dynamic import",
|
||||
"chunks_config": {
|
||||
"vendor-react": ["react", "react-dom"],
|
||||
"vendor-codemirror": ["@codemirror/state", "@codemirror/view", "@codemirror/commands", "@codemirror/lang-markdown", "@codemirror/language", "@codemirror/search", "@codemirror/theme-one-dark"],
|
||||
"vendor-markdown": ["unified", "remark-parse", "remark-gfm", "remark-rehype", "rehype-raw", "rehype-sanitize", "rehype-stringify", "rehype-highlight"],
|
||||
"vendor-db": ["dexie"]
|
||||
},
|
||||
"dynamic_import_targets": [
|
||||
"rehype-highlight (仅 Preview 组件需要)"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "MEDIUM - 代码分割后需验证 Electron 打包正确性和加载时序"
|
||||
},
|
||||
|
||||
"SE-01_csp": {
|
||||
"id": "SE-01",
|
||||
"title": "CSP 配置",
|
||||
"solution": "在 main process 的 BrowserWindow 中通过 session.defaultSession.webRequest 配置 CSP header。",
|
||||
"implementation": {
|
||||
"file": "src/main/window-manager.ts",
|
||||
"approach": "session.webRequest.onHeadersReceived",
|
||||
"csp_policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' file: data:; font-src 'self' data:;",
|
||||
"notes": "style-src 需要 'unsafe-inline' 因为 CodeMirror 通过 JS 注入内联样式。rehype-highlight 引入 highlight.js 样式也需要考虑。"
|
||||
},
|
||||
"dependencies": ["PF-04"],
|
||||
"risk": "MEDIUM - CSP 可能阻断 CodeMirror 样式注入,需测试"
|
||||
}
|
||||
},
|
||||
|
||||
"dependency_graph": {
|
||||
"description": "优化项之间的依赖关系。箭头表示 'A -> B' 意味着 B 依赖 A(A 需先完成)。",
|
||||
"edges": [
|
||||
{ "from": "CQ-01", "to": "CQ-02", "reason": "Prettier 需与 ESLint 集成 (eslint-config-prettier)" },
|
||||
{ "from": "CQ-01", "to": "AR-01", "reason": "组件拆分前需确保代码质量基线" },
|
||||
{ "from": "CQ-01", "to": "AR-02", "reason": "组件拆分前需确保代码质量基线" },
|
||||
{ "from": "CQ-01", "to": "PF-03", "reason": "React.memo 添加前需 ESLint 规则配合" },
|
||||
{ "from": "CQ-01", "to": "DX-05", "reason": "lint-staged 依赖 ESLint 配置" },
|
||||
{ "from": "CQ-01", "to": "DX-01", "reason": "测试代码也需遵循 ESLint 规则" },
|
||||
{ "from": "CQ-04", "to": "DX-02", "reason": "preload 类型安全依赖类型完善" },
|
||||
{ "from": "PF-01", "to": "PF-02", "reason": "Zustand 选择器优化涉及 Preview 组件,需配合 Markdown 缓存" },
|
||||
{ "from": "PF-01", "to": "PF-07", "reason": "Preview 防抖依赖渲染管道已优化" },
|
||||
{ "from": "PF-02", "to": "PF-07", "reason": "Preview 防抖依赖 activeTab 选择器" },
|
||||
{ "from": "PF-02", "to": "PF-03", "reason": "React.memo 效果依赖选择器优化减少不必要更新" },
|
||||
{ "from": "AR-01", "to": "UX-01", "reason": "ConfirmDialog 依赖组件拆分后的目录结构" },
|
||||
{ "from": "AR-01", "to": "UX-02", "reason": "LoadingSpinner 依赖组件拆分后的目录结构" },
|
||||
{ "from": "PF-01", "to": "UX-03", "reason": "分屏模式的预览面板依赖 Markdown 缓存" },
|
||||
{ "from": "PF-02", "to": "UX-03", "reason": "分屏模式需要高效的 store 订阅" },
|
||||
{ "from": "PF-04", "to": "UX-03", "reason": "分屏模式的编辑器依赖 Compartment 动态配置" },
|
||||
{ "from": "PF-04", "to": "SE-01", "reason": "CSP 配置需在 Compartment 稳定后进行,避免相互影响" },
|
||||
{ "from": "AR-05", "to": "AR-01", "reason": "ErrorBoundary 应在 App.tsx 拆分前完成,保护重构过程" }
|
||||
],
|
||||
"parallelizable": [
|
||||
["CQ-01", "AR-05", "DX-07"],
|
||||
["PF-01", "PF-04", "PF-08"],
|
||||
["CQ-04", "CQ-05", "AR-03", "AR-04"],
|
||||
["UX-01", "UX-02", "UX-05"],
|
||||
["DX-01", "DX-04"]
|
||||
]
|
||||
},
|
||||
|
||||
"risk_assessment": {
|
||||
"high_risk": [
|
||||
{
|
||||
"id": "PF-04",
|
||||
"title": "CodeMirror Compartment 重构",
|
||||
"risk_level": "HIGH",
|
||||
"description": "影响编辑器核心初始化流程。Compartment reconfigure 如果处理不当,可能导致编辑器状态丢失(光标位置、选区、undo 历史)。",
|
||||
"impact": "编辑器完全不可用",
|
||||
"probability": "MEDIUM",
|
||||
"mitigation": [
|
||||
"保留现有销毁重建逻辑作为 fallback(feature flag 控制)",
|
||||
"编写测试验证切换主题后光标位置和 undo 历史保留",
|
||||
"逐步迁移:先用 Compartment 切换主题,验证稳定后再扩展到其他扩展"
|
||||
],
|
||||
"rollback": "revert useCodeMirror.ts 到使用前的版本"
|
||||
},
|
||||
{
|
||||
"id": "UX-03",
|
||||
"title": "分屏模式",
|
||||
"risk_level": "HIGH",
|
||||
"description": "需要同时维护 Editor 和 Preview 两个实例,可能增加内存占用和 CPU 消耗。滚动同步逻辑复杂。",
|
||||
"impact": "性能下降、内存溢出(大文件场景)",
|
||||
"probability": "MEDIUM",
|
||||
"mitigation": [
|
||||
"Preview 面板使用延迟加载(只在 split 模式激活时挂载)",
|
||||
"大文件场景下限制预览刷新频率(500ms debounce)",
|
||||
"考虑虚拟化预览(只渲染可视区域)"
|
||||
],
|
||||
"rollback": "ViewMode 从 'editor' | 'preview' | 'split' 移除 'split' 值"
|
||||
}
|
||||
],
|
||||
"medium_risk": [
|
||||
{
|
||||
"id": "PF-02",
|
||||
"title": "Zustand 选择器重构",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "选择器重构可能遗漏某些订阅点,导致某些组件在 activeTab 变化时不再更新。",
|
||||
"impact": "编辑器/预览/状态栏内容不同步",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"使用 React DevTools Profiler 逐一验证每个受影响组件的重渲染行为",
|
||||
"逐个文件修改,每修改一个文件后手动测试",
|
||||
"编写 tabStore 选择器的单元测试"
|
||||
],
|
||||
"rollback": "逐文件恢复原始订阅方式"
|
||||
},
|
||||
{
|
||||
"id": "SE-01",
|
||||
"title": "CSP 配置",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "CSP 配置过于严格可能阻断 CodeMirror 内联样式注入或 rehype-highlight 的样式。",
|
||||
"impact": "编辑器或预览样式丢失",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"使用 'unsafe-inline' 允许 style-src(CodeMirror 依赖)",
|
||||
"分步启用 CSP 先 report-only 模式测试",
|
||||
"测试所有主题切换和 Markdown 语法高亮场景"
|
||||
],
|
||||
"rollback": "移除 CSP header 配置"
|
||||
},
|
||||
{
|
||||
"id": "AR-03",
|
||||
"title": "统一设置加载",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "将两次 IndexedDB 读取合并为一次可能改变设置加载时序,导致首次渲染闪烁或主题闪烁。",
|
||||
"impact": "首次加载时短暂显示默认主题再切换到用户主题",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"在设置加载完成前显示 loading 状态(不渲染 App)",
|
||||
"使用 init() 方式在 main.tsx 中等待设置加载完成后再渲染"
|
||||
],
|
||||
"rollback": "恢复 useTheme 和 useSettings 各自独立加载"
|
||||
},
|
||||
{
|
||||
"id": "PF-08",
|
||||
"title": "IndexedDB 去抖",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "去抖延迟期间如果 Electron 进程被杀或用户关闭窗口,可能丢失最后一次保存。",
|
||||
"impact": "用户数据丢失(最后一次编辑)",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"在 beforeunload / window close 事件中 flush debounce",
|
||||
"使用 navigator.sendBeacon 或 Electron app.on('before-quit') 事件触发同步保存",
|
||||
"记录 dirty flag,关闭前检查"
|
||||
],
|
||||
"rollback": "恢复 setTimeout(fn, 0) 立即保存"
|
||||
}
|
||||
],
|
||||
"low_risk": [
|
||||
{
|
||||
"id": "CQ-01",
|
||||
"title": "ESLint 配置",
|
||||
"risk_level": "LOW",
|
||||
"description": "纯增量添加,不影响运行时行为。",
|
||||
"mitigation": ["确保 .eslintignore 排除构建产物"]
|
||||
},
|
||||
{
|
||||
"id": "PF-01",
|
||||
"title": "Markdown 处理器缓存",
|
||||
"risk_level": "LOW",
|
||||
"description": "unified processor 是无状态管道,缓存复用安全。",
|
||||
"mitigation": ["设置 max cache size 防止内存泄漏"]
|
||||
},
|
||||
{
|
||||
"id": "PF-07",
|
||||
"title": "Preview 防抖",
|
||||
"risk_level": "LOW",
|
||||
"description": "防抖只是延迟渲染,不影响最终结果。",
|
||||
"mitigation": ["防抖时间可配置,默认 150ms"]
|
||||
},
|
||||
{
|
||||
"id": "AR-05",
|
||||
"title": "ErrorBoundary",
|
||||
"risk_level": "LOW",
|
||||
"description": "纯增量添加,只在出错时生效。",
|
||||
"mitigation": ["测试注入错误验证 ErrorBoundary 正常捕获"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"summary": {
|
||||
"total_optimization_items": 35,
|
||||
"total_new_files": 28,
|
||||
"total_modified_files": 22,
|
||||
"total_phases": 6,
|
||||
"estimated_total_effort": "12-18 天",
|
||||
"critical_path": "CQ-01 → AR-05 → AR-01 → UX-01 → DX-05",
|
||||
"parallel_speedup_potential": "约 30-40%(Phase 1 和 Phase 2 可部分并行)",
|
||||
"highest_impact_items": ["PF-02", "PF-01", "PF-04", "AR-05"],
|
||||
"recommended_execution_order": [
|
||||
"Phase 1 (Day 1-2): ESLint + Prettier + ErrorBoundary — 建立基线",
|
||||
"Phase 2 (Day 3-4): 性能修复 — 解决核心瓶颈",
|
||||
"Phase 3 (Day 5-7): 架构重构 — 提升可维护性",
|
||||
"Phase 4 (Day 8-10): UX 改进 — 提升用户体验",
|
||||
"Phase 5 (Day 11-13): 测试 + 文档 — 建立质量保障",
|
||||
"Phase 6 (Day 14-18): 高级功能 — 锦上添花"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"reportTitle": "MarkLite v0.1.1 代码质量优化交付报告",
|
||||
"reportDate": "2026-06-03",
|
||||
"version": "0.1.1",
|
||||
"author": "Asset Custodian (多Agent协作)",
|
||||
|
||||
"optimizationSummary": {
|
||||
"objective": "通过多Agent协作系统对 MarkLite 项目进行全方位代码质量优化和性能改进,涵盖代码质量审查、性能优化、UI/UX改进、类型完善和测试覆盖",
|
||||
"scope": "src/renderer/ 目录下全部前端代码,包括组件、状态管理、工具库、样式和测试",
|
||||
"totalPhases": 5,
|
||||
"completedOptimizations": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "基础设施建设",
|
||||
"items": [
|
||||
"ESLint flat config (typescript-eslint + react-hooks + react-refresh)",
|
||||
"lint-staged + husky Git pre-commit 钩子",
|
||||
"ErrorBoundary 全局错误边界组件"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"name": "性能优化",
|
||||
"items": [
|
||||
"Markdown 处理器 LRU 缓存(最大10条,PF-01)",
|
||||
"Zustand store 选择器优化(细粒度订阅,PF-02)",
|
||||
"CodeMirror Compartment 主题热切换(PF-04)",
|
||||
"预览区防抖机制(150ms,PF-05)"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"name": "代码架构改进",
|
||||
"items": [
|
||||
"sidebarStore 精简与 loadFromDB 优化",
|
||||
"useTheme 统一由 useSettingsInit 管理(AR-04)",
|
||||
"统一错误处理模块 errorHandler.ts(CQ-05)",
|
||||
"组件选择器优化减少不必要重渲染"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"name": "UI/UX 改进",
|
||||
"items": [
|
||||
"ConfirmDialog 通用确认对话框(支持 focus trap)",
|
||||
"LoadingSpinner 加载状态组件",
|
||||
"Toast 通知系统升级",
|
||||
"可访问性改进(ARIA属性、键盘导航)"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"name": "测试与文档",
|
||||
"items": [
|
||||
"Vitest 测试框架配置(jsdom + globals)",
|
||||
"5个测试文件,78个测试用例全部通过",
|
||||
"DESIGN.md 和 README.md 文档更新",
|
||||
"husky + lint-staged Git hooks 集成"
|
||||
],
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"qualityReview": {
|
||||
"score": 82,
|
||||
"maxScore": 100,
|
||||
"issuesFound": "multiple",
|
||||
"issuesResolved": "all",
|
||||
"status": "通过"
|
||||
}
|
||||
},
|
||||
|
||||
"fileChangeStatistics": {
|
||||
"scope": "v0.1.1 优化提交 (commit 3cecb0f)",
|
||||
"totalFilesChanged": 16,
|
||||
"insertions": 106,
|
||||
"deletions": 66,
|
||||
"netChange": "+40 lines",
|
||||
"modifiedFiles": [
|
||||
"DESIGN.md",
|
||||
"README.md",
|
||||
"package.json",
|
||||
"src/renderer/App.tsx",
|
||||
"src/renderer/components/Editor/Editor.tsx",
|
||||
"src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"src/renderer/components/Preview/Preview.tsx",
|
||||
"src/renderer/components/Sidebar/Sidebar.tsx",
|
||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"src/renderer/components/TabBar/TabBar.tsx",
|
||||
"src/renderer/components/Toolbar/Toolbar.tsx",
|
||||
"src/renderer/hooks/useDragDrop.ts",
|
||||
"src/renderer/hooks/useSettings.ts",
|
||||
"src/renderer/hooks/useTheme.ts",
|
||||
"src/renderer/stores/sidebarStore.ts",
|
||||
"src/renderer/stores/tabStore.ts"
|
||||
],
|
||||
"newFiles": [],
|
||||
"projectTotals": {
|
||||
"sourceFileCount": 62,
|
||||
"totalSourceLines": 4808,
|
||||
"componentDirs": 16,
|
||||
"testFiles": 5,
|
||||
"storeModules": 3,
|
||||
"hookModules": 14,
|
||||
"typeModules": 5,
|
||||
"libModules": 4
|
||||
}
|
||||
},
|
||||
|
||||
"performanceImprovements": {
|
||||
"markdownProcessorCache": {
|
||||
"id": "PF-01",
|
||||
"mechanism": "LRU 缓存(Map-based,最大10条)",
|
||||
"implementation": "src/renderer/lib/markdown.ts",
|
||||
"description": "基于文件路径缓存已构建的 unified 处理器实例,LRU淘汰策略避免内存泄漏",
|
||||
"expectedImprovement": "重复渲染同一文件时跳过处理器构建阶段,预览刷新延迟降低"
|
||||
},
|
||||
"zustandSelectorOptimization": {
|
||||
"id": "PF-02",
|
||||
"mechanism": "细粒度选择器订阅",
|
||||
"implementation": "src/renderer/stores/sidebarStore.ts, tabStore.ts, editorStore.ts",
|
||||
"description": "sidebarStore 精简为单一状态源,移除冗余字段;组件通过原子选择器订阅最小状态集",
|
||||
"expectedImprovement": "减少无关状态变化触发的组件重渲染"
|
||||
},
|
||||
"codemirrorThemeCompartment": {
|
||||
"id": "PF-04",
|
||||
"mechanism": "CodeMirror Compartment.reconfigure()",
|
||||
"implementation": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"description": "创建 themeCompartment,darkMode 变化时通过 reconfigure() 交换主题扩展,不重建 EditorView",
|
||||
"expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置"
|
||||
},
|
||||
"previewDebounce": {
|
||||
"id": "PF-05",
|
||||
"mechanism": "150ms 防抖(setTimeout + useRef)",
|
||||
"implementation": "src/renderer/components/Preview/Preview.tsx",
|
||||
"description": "编辑内容变化后延迟150ms再触发预览渲染,快速连续输入时合并为单次渲染",
|
||||
"expectedImprovement": "大量快速输入时预览区渲染次数显著减少,降低 CPU 使用"
|
||||
}
|
||||
},
|
||||
|
||||
"qualityMetrics": {
|
||||
"typescript": {
|
||||
"command": "npx tsc --noEmit",
|
||||
"errors": 0,
|
||||
"status": "PASS",
|
||||
"strictMode": true
|
||||
},
|
||||
"eslint": {
|
||||
"command": "npx eslint src/ --ext .ts,.tsx",
|
||||
"errors": 0,
|
||||
"warnings": 7,
|
||||
"warningDetails": [
|
||||
"react-hooks/exhaustive-deps: useEffect missing dependency 'activeTab' (1处,可接受的业务逻辑)",
|
||||
"no-console: console 语句 (6处,错误处理和调试日志中合理使用)"
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"tests": {
|
||||
"framework": "Vitest 4.1.8",
|
||||
"environment": "jsdom",
|
||||
"totalTestFiles": 5,
|
||||
"totalTests": 78,
|
||||
"passed": 78,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"duration": "55ms (tests only), 9.68s (total)",
|
||||
"testSuites": [
|
||||
{ "file": "src/renderer/lib/__tests__/errorHandler.test.ts", "tests": 12 },
|
||||
{ "file": "src/renderer/stores/__tests__/tabStore.test.ts", "tests": 17 },
|
||||
{ "file": "src/renderer/lib/__tests__/fileUtils.test.ts", "tests": 21 },
|
||||
{ "file": "src/renderer/stores/__tests__/editorStore.test.ts", "tests": 12 },
|
||||
{ "file": "src/renderer/lib/__tests__/markdown.test.ts", "tests": 16 }
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"coverage": {
|
||||
"status": "未配置 @vitest/coverage-v8 依赖",
|
||||
"note": "vitest.config.ts 已配置 v8 provider,需安装 @vitest/coverage-v8 后启用"
|
||||
},
|
||||
"qualityScore": {
|
||||
"reviewScore": 82,
|
||||
"maxScore": 100,
|
||||
"rating": "良好",
|
||||
"breakdown": {
|
||||
"codeStructure": "85/100 - 组件划分合理,目录结构清晰",
|
||||
"typeScript": "90/100 - 0编译错误,类型覆盖良好",
|
||||
"errorHandling": "80/100 - 统一错误处理模块,ErrorBoundary",
|
||||
"performance": "78/100 - LRU缓存、防抖、Compartment优化",
|
||||
"testing": "75/100 - 78测试覆盖核心逻辑,缺少集成测试",
|
||||
"documentation": "80/100 - DESIGN.md + README.md 更新完善",
|
||||
"accessibility": "70/100 - ConfirmDialog focus trap,待扩展"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"subsequentRecommendations": {
|
||||
"phase6AdvancedFeatures": [
|
||||
{
|
||||
"id": "AF-01",
|
||||
"title": "虚拟滚动",
|
||||
"description": "FileTree 和 TabBar 在大量文件/标签时引入虚拟列表(react-window 或自实现)",
|
||||
"priority": "中",
|
||||
"effort": "中"
|
||||
},
|
||||
{
|
||||
"id": "AF-02",
|
||||
"title": "Web Worker Markdown 渲染",
|
||||
"description": "将 Markdown 解析移至 Web Worker,主线程零阻塞",
|
||||
"priority": "高",
|
||||
"effort": "高"
|
||||
},
|
||||
{
|
||||
"id": "AF-03",
|
||||
"title": "增量编辑同步",
|
||||
"description": "基于 CodeMirror transactions 的增量 diff 更新预览,替代全量重渲染",
|
||||
"priority": "高",
|
||||
"effort": "高"
|
||||
},
|
||||
{
|
||||
"id": "AF-04",
|
||||
"title": "i18n 国际化",
|
||||
"description": "提取硬编码中文字符串到 i18n 资源文件",
|
||||
"priority": "低",
|
||||
"effort": "中"
|
||||
},
|
||||
{
|
||||
"id": "AF-05",
|
||||
"title": "代码覆盖率提升",
|
||||
"description": "安装 @vitest/coverage-v8,目标覆盖率 70%+,补充组件集成测试",
|
||||
"priority": "高",
|
||||
"effort": "中"
|
||||
}
|
||||
],
|
||||
"longTermMaintenance": [
|
||||
"定期更新依赖(Electron、CodeMirror、Zustand),关注 breaking changes",
|
||||
"保持 ESLint 规则集更新,跟进 typescript-eslint 新版本",
|
||||
"每季度进行一次代码质量审查(参照本次多Agent审查流程)",
|
||||
"考虑引入 CI/CD 流水线自动化测试和 lint 检查",
|
||||
"监控 Electron 安全公告,及时升级版本修复漏洞",
|
||||
"补充 E2E 测试(Playwright 或 Spectron),覆盖核心用户流程",
|
||||
"建立代码贡献规范(CONTRIBUTING.md),统一 PR 审查流程"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
{
|
||||
"project": "MarkLite",
|
||||
"version": "0.1.1",
|
||||
"analysisDate": "2026-06-03",
|
||||
"techStack": {
|
||||
"electron": "28.3.3",
|
||||
"react": "18.3.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "electron-vite 3.0.0",
|
||||
"stateManagement": "zustand 5.0.6",
|
||||
"editor": "CodeMirror 6",
|
||||
"database": "Dexie (IndexedDB)"
|
||||
},
|
||||
"currentStatus": {
|
||||
"typescriptCheck": "PASS",
|
||||
"eslintConfig": "MISSING",
|
||||
"testCoverage": "NONE",
|
||||
"documentation": "NONE",
|
||||
"ci": "NONE"
|
||||
},
|
||||
"codebaseStats": {
|
||||
"totalSourceFiles": 44,
|
||||
"mainProcessFiles": 5,
|
||||
"preloadFiles": 1,
|
||||
"rendererFiles": 36,
|
||||
"sharedFiles": 2,
|
||||
"components": 14,
|
||||
"stores": 3,
|
||||
"hooks": 7,
|
||||
"dbRepositories": 3,
|
||||
"typeDefinitions": 5,
|
||||
"styleFiles": 3
|
||||
},
|
||||
"optimizationRequirements": {
|
||||
"1_codeQuality": {
|
||||
"title": "代码质量与规范",
|
||||
"priority": "HIGH",
|
||||
"items": [
|
||||
{
|
||||
"id": "CQ-01",
|
||||
"title": "配置 ESLint (Flat Config)",
|
||||
"priority": "P0-CRITICAL",
|
||||
"description": "package.json 中已有 @typescript-eslint/eslint-plugin、eslint、eslint-plugin-react-hooks 等依赖,但缺少 eslint.config.js 配置文件。npm run lint 无法正常工作。",
|
||||
"currentState": "devDependencies 已安装但无配置文件",
|
||||
"expectedState": "创建 eslint.config.js (ESLint v9 flat config 格式),配置 TypeScript + React + React Hooks 规则",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"configFormat": "ESM flat config (eslint.config.js)",
|
||||
"plugins": ["@typescript-eslint/eslint-plugin", "eslint-plugin-react-hooks", "eslint-plugin-react-refresh"],
|
||||
"rules": [
|
||||
"typescript-eslint/recommended",
|
||||
"react-hooks/recommended",
|
||||
"no-unused-vars (error)",
|
||||
"no-console (warn)",
|
||||
"prefer-const"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CQ-02",
|
||||
"title": "添加 Prettier 代码格式化",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "项目无统一代码格式化工具,代码风格靠人工维护(缩进、引号、分号等不一致风险高)。",
|
||||
"currentState": "无 Prettier 配置",
|
||||
"expectedState": "添加 .prettierrc 配置 + 与 ESLint 集成",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"config": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CQ-03",
|
||||
"title": "添加 .editorconfig",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "统一编辑器行为配置,确保不同开发者环境一致。",
|
||||
"currentState": "无 .editorconfig",
|
||||
"expectedState": "添加 .editorconfig 文件",
|
||||
"effort": "trivial"
|
||||
},
|
||||
{
|
||||
"id": "CQ-04",
|
||||
"title": "修复 TypeScript 严格模式下的隐式 any",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "虽然 tsc --noEmit 通过,但部分回调参数缺少类型标注(如 CustomEvent detail),依赖类型推断。",
|
||||
"currentState": "编译通过但存在隐式类型",
|
||||
"expectedState": "所有函数参数显式标注类型",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "CQ-05",
|
||||
"title": "统一错误处理模式",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "多处 catch 块为空或仅 console.error,用户看不到有意义的错误信息。App.tsx 中 showToast('操作失败') 过于笼统。",
|
||||
"currentState": "分散的 try-catch,错误消息不具体",
|
||||
"expectedState": "统一的错误处理函数,包含具体错误描述和日志",
|
||||
"effort": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"2_performance": {
|
||||
"title": "性能优化",
|
||||
"priority": "HIGH",
|
||||
"items": [
|
||||
{
|
||||
"id": "PF-01",
|
||||
"title": "Markdown 渲染处理器缓存",
|
||||
"priority": "P0-CRITICAL",
|
||||
"description": "renderMarkdown() 每次调用都创建新的 unified() 处理器管道(含 7 个插件),大文件渲染时开销显著。unified 处理器创建后应缓存复用。",
|
||||
"currentState": "每次渲染创建新 processor (7个插件)",
|
||||
"expectedState": "缓存 processor 实例,仅在 filePath 变化时重建",
|
||||
"effort": "small",
|
||||
"impact": "HIGH - 减少每次渲染的初始化开销",
|
||||
"details": {
|
||||
"location": "src/renderer/lib/markdown.ts",
|
||||
"solution": "使用 Map<string, Processor> 按 filePath 缓存,或使用 useMemo 在组件层面缓存"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PF-02",
|
||||
"title": "Zustand 选择器优化 - 避免订阅整个 tabs 数组",
|
||||
"priority": "P0-CRITICAL",
|
||||
"description": "Editor.tsx、Preview.tsx、StatusBar.tsx、Sidebar.tsx 都订阅 tabs 数组然后 .find() 查找 activeTab。每次任何 tab 的 content 变化都会触发这些组件重渲染。",
|
||||
"currentState": "const tabs = useTabStore(s => s.tabs); const activeTab = tabs.find(...)",
|
||||
"expectedState": "使用 useShallow 或自定义选择器仅订阅 activeTab",
|
||||
"effort": "medium",
|
||||
"impact": "HIGH - 减少不必要的重渲染",
|
||||
"affectedFiles": [
|
||||
"src/renderer/components/Editor/Editor.tsx",
|
||||
"src/renderer/components/Preview/Preview.tsx",
|
||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"src/renderer/components/Sidebar/Sidebar.tsx"
|
||||
],
|
||||
"solution": "在 tabStore 中添加 getActiveTab 选择器方法,或使用 zustand useShallow"
|
||||
},
|
||||
{
|
||||
"id": "PF-03",
|
||||
"title": "React.memo 包装纯展示组件",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "Toolbar、StatusBar、Icons 等纯展示组件未使用 React.memo,父组件重渲染时会连带重渲染。",
|
||||
"currentState": "无任何组件使用 React.memo",
|
||||
"expectedState": "对 Toolbar、StatusBar、TabBar、Preview 等组件使用 memo 包装",
|
||||
"effort": "small",
|
||||
"impact": "MEDIUM"
|
||||
},
|
||||
{
|
||||
"id": "PF-04",
|
||||
"title": "CodeMirror 主题切换优化",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "useCodeMirror.ts 中 darkMode 变化时销毁并重建整个 EditorView(第93行),导致编辑器完全重新初始化。",
|
||||
"currentState": "darkMode 变化 → 整个 EditorView 销毁重建",
|
||||
"expectedState": "使用 CodeMirror Compartment 动态切换主题扩展",
|
||||
"effort": "medium",
|
||||
"impact": "HIGH - 切换主题时保持编辑器状态",
|
||||
"details": {
|
||||
"location": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"solution": "使用 @codemirror/state 的 Compartment API 动态 reconfigure 主题扩展"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PF-05",
|
||||
"title": "CodeMirror 内容更新优化",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "setContent() 使用 from:0 到 length 全量替换,对大文件(10万行+)性能差。应使用事务 diff 或仅在内容实际变化时替换。",
|
||||
"currentState": "全量替换: changes: { from: 0, to: current.length, insert: newContent }",
|
||||
"expectedState": "仅在内容不同时替换,大文件考虑增量更新",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "PF-06",
|
||||
"title": "Bundle 大小优化 - 代码分割",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "所有渲染进程代码打包为单一 bundle。rehype-highlight (含 highlight.js)、CodeMirror 等大型库未做懒加载。",
|
||||
"currentState": "单 bundle,无代码分割",
|
||||
"expectedState": "动态 import 预览相关库、分割 vendor chunk",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"chunks": [
|
||||
"vendor-react: react + react-dom",
|
||||
"vendor-codemirror: @codemirror/*",
|
||||
"vendor-markdown: unified + remark + rehype 生态",
|
||||
"app: 业务代码"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PF-07",
|
||||
"title": "Preview 渲染防抖",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "Preview.tsx 在 activeTab?.content 变化时立即触发异步渲染。快速输入时会频繁调用 renderMarkdown。",
|
||||
"currentState": "content 变化立即渲染",
|
||||
"expectedState": "添加 150-300ms 防抖,避免快速输入时频繁渲染",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "PF-08",
|
||||
"title": "保存到 IndexedDB 的去抖优化",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "tabStore 中多处使用 setTimeout(() => saveToDB(), 0) 异步保存,但没有去抖。频繁操作时会多次写入。",
|
||||
"currentState": "setTimeout(fn, 0) 立即触发",
|
||||
"expectedState": "使用 debounce (300-500ms) 合并多次保存",
|
||||
"effort": "small"
|
||||
}
|
||||
]
|
||||
},
|
||||
"3_architecture": {
|
||||
"title": "架构优化",
|
||||
"priority": "MEDIUM",
|
||||
"items": [
|
||||
{
|
||||
"id": "AR-01",
|
||||
"title": "App.tsx 拆分 - About 对话框提取",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "App.tsx 有 307 行,About 对话框 JSX 内联(约 40 行),文件打开/保存逻辑也堆积在组件中。",
|
||||
"currentState": "App.tsx 包含 About 弹窗、文件操作、事件监听等所有逻辑",
|
||||
"expectedState": "提取 AboutDialog 组件、提取 useFileOperations hook",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"extractions": [
|
||||
{
|
||||
"name": "AboutDialog",
|
||||
"from": "App.tsx lines 261-300",
|
||||
"to": "src/renderer/components/AboutDialog/AboutDialog.tsx"
|
||||
},
|
||||
{
|
||||
"name": "useFileOperations",
|
||||
"description": "提取 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 为自定义 hook",
|
||||
"to": "src/renderer/hooks/useFileOperations.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "AR-02",
|
||||
"title": "Sidebar 组件拆分",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "Sidebar.tsx 有 241 行,包含 FileTree 递归组件、resize 逻辑、独立文件列表等。应拆分为独立文件。",
|
||||
"currentState": "FileTree 组件定义在 Sidebar.tsx 内",
|
||||
"expectedState": "FileTree 提取为独立组件,resize 逻辑提取为 hook",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"extractions": [
|
||||
{
|
||||
"name": "FileTree",
|
||||
"from": "Sidebar.tsx lines 180-239",
|
||||
"to": "src/renderer/components/Sidebar/FileTree.tsx"
|
||||
},
|
||||
{
|
||||
"name": "useSidebarResize",
|
||||
"description": "提取 resize 相关逻辑为自定义 hook",
|
||||
"to": "src/renderer/hooks/useSidebarResize.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "AR-03",
|
||||
"title": "统一设置加载 - 消除 useTheme 和 useSettings 的重复加载",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "useTheme 和 useSettings 各自独立调用 settingsRepository.load(),导致启动时两次 IndexedDB 读取。",
|
||||
"currentState": "useTheme.load() + useSettings.load() = 两次 DB 读取",
|
||||
"expectedState": "统一为单个 useSettingsInit hook,一次加载分发到 store",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-04",
|
||||
"title": "消除 DOM CustomEvent 中间层",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "useFileWatch.ts 通过 window.dispatchEvent(CustomEvent) 传递文件修改事件,App.tsx 再 addEventListener 监听。应直接通过 Zustand store 或回调传递。",
|
||||
"currentState": "IPC → CustomEvent DOM → App.tsx listener",
|
||||
"expectedState": "IPC → Zustand store action → 组件响应",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-05",
|
||||
"title": "添加 React ErrorBoundary",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "无全局错误边界。任何渲染错误会导致白屏。Electron 应用中尤其需要,因为用户无法刷新页面恢复。",
|
||||
"currentState": "无 ErrorBoundary",
|
||||
"expectedState": "添加 ErrorBoundary 组件包裹 App,显示错误信息和重启按钮",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-06",
|
||||
"title": "Icons 组件按需加载",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "Icons.tsx 有 216 行,包含 16 个 SVG 图标组件。虽然是函数组件有 tree-shaking,但单文件过大影响维护。",
|
||||
"currentState": "16 个图标定义在单文件",
|
||||
"expectedState": "按功能模块拆分: toolbar-icons.tsx, sidebar-icons.tsx, app-icons.tsx",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-07",
|
||||
"title": "tabStore 异步操作模式统一",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "tabStore 中大量使用 setTimeout(() => saveToDB(), 0) 和 setTimeout(() => tabRepository.saveActiveTabId(), 0)。模式不统一且容易出错。",
|
||||
"currentState": "分散的 setTimeout 调用",
|
||||
"expectedState": "使用 Zustand middleware (subscribeWithSelector) 或统一的 persist 逻辑",
|
||||
"effort": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4_userExperience": {
|
||||
"title": "用户体验",
|
||||
"priority": "MEDIUM",
|
||||
"items": [
|
||||
{
|
||||
"id": "UX-01",
|
||||
"title": "替换原生 confirm() 为自定义确认对话框",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "TabBar.tsx 和 useUnsavedWarning.ts 使用浏览器原生 confirm() 对话框,样式丑陋且不可自定义。Electron 应用应使用自定义模态框或 electron.dialog。",
|
||||
"currentState": "5 处使用 confirm() 原生对话框",
|
||||
"expectedState": "自定义 ConfirmDialog 组件或使用 electron.dialog.showMessageBox",
|
||||
"effort": "medium",
|
||||
"affectedLocations": [
|
||||
"src/renderer/components/TabBar/TabBar.tsx (4处)",
|
||||
"src/renderer/hooks/useUnsavedWarning.ts (1处)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "UX-02",
|
||||
"title": "添加加载状态指示",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "文件打开、目录树加载、Markdown 渲染等异步操作无加载状态。用户无法感知操作是否正在进行。",
|
||||
"currentState": "无 loading 状态",
|
||||
"expectedState": "添加文件打开 spinner、目录树加载骨架屏、预览渲染指示器",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "UX-03",
|
||||
"title": "添加编辑器+预览分屏模式",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "当前只有 'editor' 和 'preview' 两种模式,缺少 VS Code 风格的左右分屏同时编辑和预览。",
|
||||
"currentState": "viewMode: 'editor' | 'preview'",
|
||||
"expectedState": "新增 'split' 模式: 左侧编辑器 + 右侧实时预览",
|
||||
"effort": "large",
|
||||
"details": {
|
||||
"implementation": [
|
||||
"Settings.ViewMode 新增 'split'",
|
||||
"Editor 和 Preview 同时渲染",
|
||||
"编辑器滚动同步预览",
|
||||
"Ctrl+3 快捷键"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "UX-04",
|
||||
"title": "TabBar 拖拽排序",
|
||||
"priority": "P3-LOW",
|
||||
"description": "标签页不支持拖拽排序,无法自定义标签顺序。",
|
||||
"currentState": "标签页固定顺序",
|
||||
"expectedState": "支持拖拽重新排列标签页",
|
||||
"effort": "large"
|
||||
},
|
||||
{
|
||||
"id": "UX-05",
|
||||
"title": "改进 Toast 通知系统",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "当前 Toast 仅支持单条消息,3秒后消失。不支持不同类型(success/error/warning)和堆叠。",
|
||||
"currentState": "单条 toast,无类型区分",
|
||||
"expectedState": "支持多条堆叠、不同类型(成功/错误/警告)、可关闭",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "UX-06",
|
||||
"title": "添加文件搜索功能 (Ctrl+P)",
|
||||
"priority": "P3-LOW",
|
||||
"description": "无快速文件搜索/打开功能。当打开文件夹后,应支持按文件名快速搜索打开。",
|
||||
"currentState": "仅支持文件夹树浏览",
|
||||
"expectedState": "Ctrl+P 快速搜索面板,模糊匹配文件名",
|
||||
"effort": "large"
|
||||
},
|
||||
{
|
||||
"id": "UX-07",
|
||||
"title": "可访问性增强",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "部分组件缺少 ARIA 属性。Sidebar resize handle 无键盘支持。文件树展开/折叠无键盘导航。",
|
||||
"currentState": "基本的 role/aria-label 已添加",
|
||||
"expectedState": "完整的键盘导航、focus 管理、屏幕阅读器支持",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"missing": [
|
||||
"resize handle 需要 role='separator' + 键盘调整",
|
||||
"文件树需要 aria-expanded、aria-owns",
|
||||
"右键菜单需要 role='menu' + 键盘导航",
|
||||
"About 对话框需要 focus trap"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"5_developmentExperience": {
|
||||
"title": "开发体验",
|
||||
"priority": "MEDIUM",
|
||||
"items": [
|
||||
{
|
||||
"id": "DX-01",
|
||||
"title": "添加单元测试框架",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "项目零测试覆盖。核心逻辑(tabStore、fileUtils、markdown 渲染)应有单元测试。",
|
||||
"currentState": "无测试文件、无测试框架",
|
||||
"expectedState": "Vitest + React Testing Library,核心模块测试覆盖 > 60%",
|
||||
"effort": "large",
|
||||
"details": {
|
||||
"framework": "vitest + @testing-library/react + jsdom",
|
||||
"priority_test_targets": [
|
||||
"src/renderer/stores/tabStore.ts - 标签页 CRUD 逻辑",
|
||||
"src/renderer/lib/fileUtils.ts - 文件名/路径工具",
|
||||
"src/renderer/lib/markdown.ts - Markdown 渲染",
|
||||
"src/renderer/db/schema.ts - 数据库 schema",
|
||||
"src/main/file-system.ts - 文件系统操作"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DX-02",
|
||||
"title": "强化 preload 类型安全",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "types/ipc.ts 定义了 ElectronAPI 接口和 IpcInvokeMap,但 preload/index.ts 未使用这些类型约束。contextBridge.exposeInMainWorld 返回值无类型检查。",
|
||||
"currentState": "preload 使用字面量对象,未引用 ElectronAPI 类型",
|
||||
"expectedState": "preload 实现强制匹配 ElectronAPI 接口类型",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"solution": "const api: ElectronAPI = { ... } 在 preload 中强制类型检查"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DX-03",
|
||||
"title": "添加类型安全的 IPC 通信层",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "types/ipc.ts 定义了 IpcInvokeMap 但未被使用。应建立类型安全的 IPC 调用包装器。",
|
||||
"currentState": "IpcInvokeMap 已定义但未使用",
|
||||
"expectedState": "渲染进程通过类型安全的 invoke 函数调用 IPC",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "DX-04",
|
||||
"title": "添加项目文档",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "无 README.md、无 CONTRIBUTING.md、无开发环境搭建文档。",
|
||||
"currentState": "无文档",
|
||||
"expectedState": "README.md 含项目介绍、安装步骤、开发指南、构建说明",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"files": [
|
||||
"README.md - 项目介绍、截图、安装、开发、构建",
|
||||
"CONTRIBUTING.md - 贡献指南、代码规范、PR 流程",
|
||||
"CHANGELOG.md - 版本变更记录"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DX-05",
|
||||
"title": "添加 Git hooks (husky + lint-staged)",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "无 pre-commit 钩子,代码质量无法在提交时保证。",
|
||||
"currentState": "无 Git hooks",
|
||||
"expectedState": "husky + lint-staged: 提交前自动运行 ESLint + 类型检查",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "DX-06",
|
||||
"title": "添加 CI/CD 配置",
|
||||
"priority": "P3-LOW",
|
||||
"description": "无持续集成配置。应添加 GitHub Actions 或 Gitee CI 配置。",
|
||||
"currentState": "无 CI 配置",
|
||||
"expectedState": "CI 流水线: lint → typecheck → test → build",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "DX-07",
|
||||
"title": "添加路径别名到 Vite 配置",
|
||||
"priority": "P3-LOW",
|
||||
"description": "tsconfig.json 定义了 @renderer/*、@main/*、@shared/* 路径别名,但 electron.vite.config.ts 中 renderer 的 alias 只有 @renderer 和 @shared,缺少 @main。",
|
||||
"currentState": "renderer alias 缺少 @main 映射",
|
||||
"expectedState": "补全所有路径别名映射",
|
||||
"effort": "trivial"
|
||||
}
|
||||
]
|
||||
},
|
||||
"6_security": {
|
||||
"title": "安全加固",
|
||||
"priority": "HIGH",
|
||||
"items": [
|
||||
{
|
||||
"id": "SE-01",
|
||||
"title": "CSP (Content Security Policy) 配置",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "未配置 CSP。Preview 使用 dangerouslySetInnerHTML 渲染 HTML,虽然 rehype-sanitize 做了清理,但缺少 CSP 作为第二道防线。",
|
||||
"currentState": "无 CSP 配置",
|
||||
"expectedState": "在 index.html 和 BrowserWindow 中配置严格 CSP",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "SE-02",
|
||||
"title": "Preview 中 file:// 协议安全",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "rehypeFixImages 将相对路径图片转为 file:// URL。虽然拒绝了 '..' 但未做更严格的路径校验(如 symlink 跟踪)。",
|
||||
"currentState": "仅检查 '..' 路径遍历",
|
||||
"expectedState": "使用 path.resolve + 检查结果路径是否在允许目录内",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "SE-03",
|
||||
"title": "Electron 安全最佳实践检查",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "需确认 BrowserWindow 配置中 webPreferences 的安全设置(nodeIntegration、contextIsolation、sandbox 等)。",
|
||||
"currentState": "未检查 window-manager.ts 中的 webPreferences",
|
||||
"expectedState": "确保 nodeIntegration=false、contextIsolation=true、sandbox=true",
|
||||
"effort": "small"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"implementationPlan": {
|
||||
"phase1_foundation": {
|
||||
"name": "基础补全 (1-2天)",
|
||||
"priority": "P0",
|
||||
"items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
|
||||
"description": "ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复"
|
||||
},
|
||||
"phase2_criticalPerf": {
|
||||
"name": "关键性能修复 (1-2天)",
|
||||
"priority": "P0",
|
||||
"items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
|
||||
"description": "Markdown 缓存 + Zustand 选择器 + CodeMirror Compartment + 防抖"
|
||||
},
|
||||
"phase3_codeQuality": {
|
||||
"name": "代码质量提升 (2-3天)",
|
||||
"priority": "P1",
|
||||
"items": ["CQ-04", "CQ-05", "AR-01", "AR-02", "AR-03", "AR-04", "PF-03"],
|
||||
"description": "类型完善 + 错误处理统一 + 组件拆分 + 重复逻辑消除"
|
||||
},
|
||||
"phase4_userExperience": {
|
||||
"name": "用户体验改进 (2-3天)",
|
||||
"priority": "P1",
|
||||
"items": ["UX-01", "UX-02", "UX-05", "UX-07"],
|
||||
"description": "自定义确认框 + 加载状态 + Toast 改进 + 可访问性"
|
||||
},
|
||||
"phase5_developerExperience": {
|
||||
"name": "开发体验 (2-3天)",
|
||||
"priority": "P2",
|
||||
"items": ["DX-01", "DX-02", "DX-04", "DX-05"],
|
||||
"description": "单元测试 + 类型安全 + 文档 + Git hooks"
|
||||
},
|
||||
"phase6_advanced": {
|
||||
"name": "高级功能 (3-5天)",
|
||||
"priority": "P2-P3",
|
||||
"items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
|
||||
"description": "代码分割 + 分屏模式 + 拖拽排序 + 搜索 + 安全加固 + CI"
|
||||
}
|
||||
},
|
||||
"riskAssessment": {
|
||||
"highRisk": [
|
||||
{
|
||||
"id": "PF-04",
|
||||
"risk": "CodeMirror Compartment 重构可能影响编辑器初始化和标签切换逻辑",
|
||||
"mitigation": "保留现有逻辑作为 fallback,逐步迁移到 Compartment"
|
||||
},
|
||||
{
|
||||
"id": "UX-03",
|
||||
"risk": "分屏模式需要同时维护两个编辑器实例,内存和性能需关注",
|
||||
"mitigation": "使用虚拟化、延迟加载预览面板"
|
||||
}
|
||||
],
|
||||
"mediumRisk": [
|
||||
{
|
||||
"id": "PF-02",
|
||||
"risk": "Zustand 选择器重构可能遗漏某些订阅点",
|
||||
"mitigation": "使用 React DevTools Profiler 验证重渲染情况"
|
||||
},
|
||||
{
|
||||
"id": "SE-01",
|
||||
"risk": "CSP 配置过于严格可能阻断 CodeMirror 样式注入",
|
||||
"mitigation": "使用 nonce 或 hash 方式允许 CodeMirror 内联样式"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: { index: resolve(__dirname, 'src/main/index.ts') }
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@shared': resolve(__dirname, 'src/shared')
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-console': 'warn',
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', 'assets/', 'lib/'],
|
||||
},
|
||||
)
|
||||
+28
-4
@@ -1,14 +1,29 @@
|
||||
{
|
||||
"name": "marklite",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"description": "Lightweight Markdown Editor for Windows",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build && electron-builder --win",
|
||||
"build:portable": "electron-vite build && electron-builder --win portable",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"lint": "eslint src/",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"tsc --noEmit --pretty"
|
||||
],
|
||||
"*.{json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"author": "MarkLite",
|
||||
"license": "MIT",
|
||||
@@ -36,6 +51,9 @@
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
@@ -46,7 +64,13 @@
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"typescript": "^5.6.3"
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.7",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.marklite.app",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import { stat } from 'fs/promises'
|
||||
import { join, basename, resolve, normalize, isAbsolute } from 'path'
|
||||
import { basename, normalize, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
function validatePath(filePath: string): boolean {
|
||||
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DX-02: Preload 类型安全声明
|
||||
* 为 window.electronAPI 提供完整的 TypeScript 类型定义,
|
||||
* 确保渲染进程访问 API 时有完整的类型提示和编译时检查。
|
||||
*/
|
||||
|
||||
import type {
|
||||
OpenFileResponse,
|
||||
ReadFileResult,
|
||||
SaveFilePayload,
|
||||
SaveAsPayload,
|
||||
SaveFileResult,
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult,
|
||||
} from '../shared/types'
|
||||
|
||||
interface ElectronAPI {
|
||||
// File operations
|
||||
openFile: () => Promise<OpenFileResponse>
|
||||
readFile: (filePath: string) => Promise<ReadFileResult>
|
||||
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
|
||||
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
|
||||
getCurrentPath: () => Promise<string | null>
|
||||
getFileStats: (filePath: string) => Promise<FileStatsResult>
|
||||
reloadFile: () => Promise<ReloadFileResult>
|
||||
|
||||
// Tab management
|
||||
tabSwitched: (filePath: string | null) => Promise<void>
|
||||
|
||||
// Window control
|
||||
forceClose: () => Promise<void>
|
||||
cancelClose: () => Promise<void>
|
||||
|
||||
// Shell
|
||||
openExternal: (url: string) => void
|
||||
|
||||
// File Tree (Sidebar)
|
||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
|
||||
// Events from main process — 返回取消订阅函数
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => () => void
|
||||
onMenuSave: (callback: () => void) => () => void
|
||||
onMenuSaveAs: (callback: () => void) => () => void
|
||||
onViewModeChange: (callback: (mode: string) => void) => () => void
|
||||
onExternalModification: (callback: (filePath: string) => void) => () => void
|
||||
onDirChanged: (callback: () => void) => () => void
|
||||
onConfirmClose: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
+67
-254
@@ -1,14 +1,17 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useSidebarStore } from './stores/sidebarStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||
import { useFileWatch } from './hooks/useFileWatch'
|
||||
import { recentFilesRepository } from './db/recentFilesRepository'
|
||||
import { useFileOperations } from './hooks/useFileOperations'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useConfirm } from './hooks/useConfirm'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
@@ -16,292 +19,102 @@ import { Preview } from './components/Preview/Preview'
|
||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||
import { Toast } from './components/Toast/Toast'
|
||||
import { ToastContainer } from './components/Toast/Toast'
|
||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { AppIcon, Gitee } from './components/Icons'
|
||||
|
||||
const APP_VERSION = 'v0.1.1'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AboutDialog } from './components/AboutDialog'
|
||||
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
|
||||
|
||||
export function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||
const loadSidebarFromDB = useSidebarStore(s => s.loadFromDB)
|
||||
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
|
||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { saveViewMode } = useSettings()
|
||||
|
||||
const [showModifiedBanner, setShowModifiedBanner] = useState(false)
|
||||
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const toastTimer = useRef<number>(0)
|
||||
|
||||
// 启动时从 IndexedDB 加载标签页和 sidebar 设置
|
||||
useEffect(() => {
|
||||
loadFromDB()
|
||||
loadSidebarFromDB()
|
||||
return () => clearTimeout(toastTimer.current)
|
||||
}, [loadFromDB, loadSidebarFromDB])
|
||||
useSettingsInit()
|
||||
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
clearTimeout(toastTimer.current)
|
||||
setToastMessage(msg)
|
||||
toastTimer.current = window.setTimeout(() => setToastMessage(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 拖拽打开
|
||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
|
||||
useDragDrop(showToast)
|
||||
|
||||
// 文件修改检测
|
||||
useFileWatch()
|
||||
|
||||
// 未保存提醒
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified))
|
||||
// UX-01: 传入 confirm 函数替代原生 confirm()
|
||||
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
||||
return confirm({
|
||||
title: '未保存的更改',
|
||||
message,
|
||||
variant: 'warning',
|
||||
confirmLabel: '不保存',
|
||||
cancelLabel: '取消'
|
||||
})
|
||||
}, [confirm])
|
||||
|
||||
// 文件外部修改事件
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const filePath = (e as CustomEvent).detail
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
setShowModifiedBanner(true)
|
||||
setModifiedFilePath(filePath)
|
||||
}
|
||||
}
|
||||
window.addEventListener('file-externally-modified', handler)
|
||||
return () => window.removeEventListener('file-externally-modified', handler)
|
||||
}, [getActiveTab])
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
useIpcListeners(handleSave, handleSaveAs)
|
||||
|
||||
// 切换标签时通知主进程更新窗口标题
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
|
||||
}, [activeTabId, tabs])
|
||||
|
||||
// 打开文件
|
||||
const handleOpenFile = useCallback(async () => {
|
||||
try {
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.openFile()
|
||||
if (result && 'filePath' in result) {
|
||||
createTab(result.filePath, result.content)
|
||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showToast('操作失败')
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||
if (result.success && result.content) {
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
|
||||
}
|
||||
}, [createTab, showToast])
|
||||
|
||||
// 保存文件
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) return
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
// 另存为
|
||||
const handleSaveAs = useCallback(async () => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) return
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
// 快捷键
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
|
||||
// 打开最近文件
|
||||
const handleOpenRecent = useCallback(async (filePath: string) => {
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
}
|
||||
}, [createTab])
|
||||
|
||||
// 视图模式切换
|
||||
const handleViewModeChange = useCallback((mode: 'editor' | 'preview') => {
|
||||
saveViewMode(mode)
|
||||
}, [saveViewMode])
|
||||
|
||||
// M-11: 主进程事件注册
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
const handleSaveAsRef = useRef(handleSaveAs)
|
||||
handleSaveRef.current = handleSave
|
||||
handleSaveAsRef.current = handleSaveAs
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const api = window.electronAPI
|
||||
|
||||
const onOpen = (data: { filePath: string; content: string }) => {
|
||||
createTab(data.filePath, data.content)
|
||||
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
const onSave = () => handleSaveRef.current()
|
||||
const onSaveAs = () => handleSaveAsRef.current()
|
||||
|
||||
const unsub1 = api.onFileOpenInTab(onOpen)
|
||||
const unsub2 = api.onMenuSave(onSave)
|
||||
const unsub3 = api.onMenuSaveAs(onSaveAs)
|
||||
|
||||
return () => {
|
||||
unsub1()
|
||||
unsub2()
|
||||
unsub3()
|
||||
}
|
||||
}, [createTab])
|
||||
|
||||
const hasTabs = tabs.length > 0
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
|
||||
return (
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile}
|
||||
onSave={handleSave}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDark={toggleDarkMode}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
|
||||
{showModifiedBanner && (
|
||||
<ModifiedBanner
|
||||
onReload={() => {
|
||||
if (window.electronAPI && modifiedFilePath) {
|
||||
window.electronAPI.readFile(modifiedFilePath).then(result => {
|
||||
if (result.success && result.content) {
|
||||
const tab = tabs.find(t => t.filePath === modifiedFilePath)
|
||||
if (tab) {
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
}
|
||||
}
|
||||
setShowModifiedBanner(false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
onDismiss={() => setShowModifiedBanner(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasTabs ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode === 'editor' && (
|
||||
<div id="editor-panel">
|
||||
<Editor darkMode={darkMode} />
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'preview' && (
|
||||
<div id="preview-panel">
|
||||
<Preview />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen
|
||||
onOpen={handleOpenFile}
|
||||
onNew={() => createTab(null, '')}
|
||||
onOpenRecent={handleOpenRecent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusBar />
|
||||
|
||||
{toastMessage && <Toast message={toastMessage} />}
|
||||
<DropOverlay />
|
||||
|
||||
{/* 关于弹框 */}
|
||||
{showAbout && (
|
||||
<div className="about-overlay" onClick={() => setShowAbout(false)} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>状态持久化</span>
|
||||
<ErrorBoundary>
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile} onSave={handleSave}
|
||||
viewMode={viewMode} onViewModeChange={saveViewMode}
|
||||
darkMode={darkMode} onToggleDark={toggleDarkMode}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
|
||||
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={() => setShowAbout(false)}>关闭</button>
|
||||
) : (
|
||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar />
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={() => setShowAbout(false)} />}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
App.displayName = 'App'
|
||||
|
||||
export default App
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.2.0'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
@@ -0,0 +1 @@
|
||||
export { AboutDialog } from './AboutDialog'
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 保存焦点并在打开时聚焦确认按钮
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
// 关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
|
||||
return () => {
|
||||
previousFocusRef.current?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
@@ -0,0 +1 @@
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { UploadCloud } from '../Icons'
|
||||
|
||||
export function DropOverlay() {
|
||||
export const DropOverlay = React.memo(function DropOverlay() {
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -9,16 +9,13 @@ export function DropOverlay() {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => prev + 1)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = () => {
|
||||
setDragCounter(0)
|
||||
}
|
||||
@@ -39,11 +36,13 @@ export function DropOverlay() {
|
||||
if (dragCounter <= 0) return null
|
||||
|
||||
return (
|
||||
<div id="drop-overlay">
|
||||
<div id="drop-overlay" role="dialog" aria-label="拖拽文件以打开">
|
||||
<div className="drop-content">
|
||||
<UploadCloud size={64} />
|
||||
<p>释放文件以打开</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
DropOverlay.displayName = 'DropOverlay'
|
||||
|
||||
@@ -8,9 +8,8 @@ interface EditorProps {
|
||||
}
|
||||
|
||||
export function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
|
||||
@@ -5,16 +5,14 @@ interface EditorToolbarProps {
|
||||
viewRef: React.MutableRefObject<EditorView | null>
|
||||
}
|
||||
|
||||
export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
export const EditorToolbar = React.memo(function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
const insertFormatting = useCallback((before: string, after: string, placeholder: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const selected = view.state.sliceDoc(from, to)
|
||||
const text = selected || placeholder
|
||||
const insert = before + text + after
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + before.length, head: from + before.length + text.length }
|
||||
@@ -25,12 +23,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
const insertLinePrefix = useCallback((prefix: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
|
||||
const { from } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const currentLine = view.state.sliceDoc(line.from, line.to)
|
||||
|
||||
// 如果已经有前缀,移除它
|
||||
if (currentLine.startsWith(prefix)) {
|
||||
view.dispatch({
|
||||
changes: { from: line.from, to: line.from + prefix.length, insert: '' }
|
||||
@@ -46,11 +41,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
const insertBlock = useCallback((text: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
|
||||
const { from } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const insertPos = line.to + 1
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: insertPos, to: insertPos, insert: '\n' + text + '\n' },
|
||||
selection: { anchor: insertPos + 1, head: insertPos + 1 + text.length }
|
||||
@@ -59,49 +52,51 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||
}, [viewRef])
|
||||
|
||||
return (
|
||||
<div className="editor-toolbar">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)">
|
||||
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)" aria-label="粗体">
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)" aria-label="斜体">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线" aria-label="删除线">
|
||||
<s>S</s>
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码" aria-label="行内代码">
|
||||
{'</>'}
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题">
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题" aria-label="一级标题">
|
||||
H1
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题" aria-label="二级标题">
|
||||
H2
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题" aria-label="三级标题">
|
||||
H3
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表">
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表" aria-label="无序列表">
|
||||
•≡
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表" aria-label="有序列表">
|
||||
1.
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用" aria-label="引用">
|
||||
❝
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块" aria-label="代码块">
|
||||
{'{ }'}
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接">
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接" aria-label="插入链接">
|
||||
🔗
|
||||
</button>
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('', '图片描述')} title="图片">
|
||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('', '图片描述')} title="图片" aria-label="插入图片">
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
EditorToolbar.displayName = 'EditorToolbar'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { EditorState, type Extension } from '@codemirror/state'
|
||||
import { EditorState, Compartment, type Extension } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars, drawSelection, rectangularSelection } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
@@ -13,22 +13,35 @@ interface UseCodeMirrorOptions {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
// PF-04: 创建主题 Compartment,用于动态切换主题而不重建 EditorView
|
||||
const themeCompartment = new Compartment()
|
||||
|
||||
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const darkModeRef = useRef(darkMode)
|
||||
|
||||
// 始终保持 onChangeRef 为最新回调
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// 初始化编辑器
|
||||
// PF-04: darkMode 变化时通过 Compartment.reconfigure() 切换主题
|
||||
useEffect(() => {
|
||||
darkModeRef.current = darkMode
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(darkMode ? oneDark : [])
|
||||
})
|
||||
}, [darkMode])
|
||||
|
||||
// 初始化编辑器(仅在首次挂载时执行)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// 中文本地化(搜索/替换面板)
|
||||
const zhPhrases = EditorState.phrases.of({
|
||||
'Find': '查找',
|
||||
'Replace': '替换',
|
||||
@@ -65,15 +78,14 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
markdown({ base: markdownLanguage }),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(update => {
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !isExternalUpdate.current) {
|
||||
onChangeRef.current(update.state.doc.toString())
|
||||
}
|
||||
})
|
||||
}),
|
||||
themeCompartment.of(darkModeRef.current ? oneDark : [])
|
||||
]
|
||||
|
||||
if (darkMode) extensions.push(oneDark)
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions
|
||||
@@ -90,7 +102,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [darkMode]) // darkMode 变化时重建
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 外部内容更新(切换标签时)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
@@ -105,8 +118,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
// 获取/设置滚动位置
|
||||
const getScrollTop = useCallback(() => {
|
||||
|
||||
const getScrollTop = useCallback((): number => {
|
||||
return viewRef.current?.scrollDOM.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
@@ -116,8 +129,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取/设置选区
|
||||
const getSelection = useCallback(() => {
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const view = viewRef.current
|
||||
if (!view) return { from: 0, to: 0 }
|
||||
const ranges = view.state.selection.ranges
|
||||
@@ -130,6 +142,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
|
||||
view.dispatch({ selection: { anchor: from, head: to } })
|
||||
view.focus()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewRef,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||
应用遇到了错误
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { File, Folder, ChevronRight } from '../Icons'
|
||||
import type { FileNode } from '../../types/file'
|
||||
|
||||
interface FileTreeProps {
|
||||
nodes: FileNode[]
|
||||
depth: number
|
||||
expandedDirs: string[]
|
||||
toggleDir: (path: string) => void
|
||||
activeTabId: string | null
|
||||
activeFilePath: string | null
|
||||
onFileClick: (path: string) => void
|
||||
}
|
||||
|
||||
/** AR-02: 从 Sidebar.tsx 提取的递归文件树组件 */
|
||||
export const FileTree = React.memo(function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
expandedDirs,
|
||||
toggleDir,
|
||||
activeFilePath,
|
||||
onFileClick
|
||||
}: FileTreeProps) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node: FileNode) => {
|
||||
const isExpanded = node.type === 'dir' && expandedDirs.includes(node.path)
|
||||
const isActive = node.type === 'file' && node.path === activeFilePath
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${isActive ? 'active' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
role="treeitem"
|
||||
aria-expanded={node.type === 'dir' ? isExpanded : undefined}
|
||||
aria-selected={isActive}
|
||||
aria-label={node.name}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'dir' ? (
|
||||
<>
|
||||
<span className={`tree-arrow ${isExpanded ? 'expanded' : ''}`} aria-hidden="true">
|
||||
<ChevronRight size={10} />
|
||||
</span>
|
||||
<span className="tree-icon" aria-hidden="true">
|
||||
<Folder size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ width: '16px', flexShrink: 0 }} />
|
||||
<span className="tree-icon" aria-hidden="true">
|
||||
<File size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tree-name">{node.name}</span>
|
||||
</div>
|
||||
{isExpanded && node.children && (
|
||||
<div role="group">
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={null}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
FileTree.displayName = 'FileTree'
|
||||
@@ -0,0 +1 @@
|
||||
export { FileTree } from './FileTree'
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
label?: string
|
||||
/** 是否全屏覆盖 */
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: 16,
|
||||
medium: 24,
|
||||
large: 36
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-02: 通用加载指示器组件
|
||||
*/
|
||||
export const LoadingSpinner = React.memo(function LoadingSpinner({
|
||||
size = 'medium',
|
||||
label,
|
||||
overlay = false
|
||||
}: LoadingSpinnerProps) {
|
||||
const px = sizeMap[size]
|
||||
|
||||
const spinner = (
|
||||
<div
|
||||
className={`loading-spinner loading-spinner-${size}`}
|
||||
role="status"
|
||||
aria-label={label || '加载中'}
|
||||
>
|
||||
<svg
|
||||
className="loading-spinner-svg"
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{label && <span className="loading-spinner-label">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!overlay) return spinner
|
||||
|
||||
return (
|
||||
<div className="loading-overlay" aria-busy="true">
|
||||
{spinner}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner'
|
||||
@@ -0,0 +1 @@
|
||||
export { LoadingSpinner } from './LoadingSpinner'
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
return (
|
||||
<div id="modified-banner">
|
||||
<div id="modified-banner" role="alert" aria-live="assertive">
|
||||
<span>文件已被外部程序修改</span>
|
||||
<button className="banner-btn" onClick={onReload}>重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss}>忽略</button>
|
||||
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">忽略</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ModifiedBanner.displayName = 'ModifiedBanner'
|
||||
|
||||
@@ -1,37 +1,63 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// 响应式渲染(无轮询,无竞态)
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
})
|
||||
}, [activeTabId, activeTab?.content])
|
||||
}
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href = link.getAttribute('href')
|
||||
const href: string | null = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
@@ -59,9 +85,16 @@ export function Preview() {
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +1,44 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File, Folder, ChevronRight } from '../Icons'
|
||||
import type { FileNode } from '../../types/file'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
||||
|
||||
export function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||
|
||||
// M-01: 归一化路径分隔符
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null
|
||||
|
||||
// 自动展开到活动文件所在的目录
|
||||
useEffect(() => {
|
||||
if (!activeFilePath || !rootPath) return
|
||||
const normActive = norm(activeFilePath)
|
||||
const normRoot = norm(rootPath)
|
||||
if (!normActive.startsWith(normRoot)) return
|
||||
|
||||
const dirsToExpand: string[] = []
|
||||
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
|
||||
let prev = ''
|
||||
while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) {
|
||||
prev = dir
|
||||
dirsToExpand.push(dir)
|
||||
dir = dir.replace(/[/\\][^/\\]+$/, '')
|
||||
}
|
||||
|
||||
if (dirsToExpand.length > 0) {
|
||||
expandDirs(dirsToExpand)
|
||||
}
|
||||
}, [activeFilePath, rootPath, expandDirs])
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (dirPath) {
|
||||
setRootPath(dirPath)
|
||||
// 展开根目录
|
||||
expandDirs([dirPath])
|
||||
const result = await window.electronAPI.readDirTree(dirPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
}
|
||||
}, [rootPath, setTree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => {
|
||||
refreshTree()
|
||||
})
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
const handleMouseUp = () => setIsResizing(false)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) {
|
||||
switchToTab(existing.id)
|
||||
} else if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab])
|
||||
|
||||
// M-01: 独立文件区(归一化路径比较)
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
@@ -120,123 +48,61 @@ export function Sidebar() {
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="sidebar" ref={sidebarRef}>
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{/* 独立文件区 */}
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section">
|
||||
<div className="independent-files-header">已打开的文件</div>
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
>
|
||||
<span className="tree-icon">
|
||||
<File size={14} />
|
||||
</span>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot"> •</span>}
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件夹目录树 */}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header">文件夹目录树</div>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={handleFileClick}
|
||||
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 文件树递归组件
|
||||
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, activeFilePath, onFileClick }: {
|
||||
nodes: FileNode[]
|
||||
depth: number
|
||||
expandedDirs: string[]
|
||||
toggleDir: (path: string) => void
|
||||
activeTabId: string | null
|
||||
activeFilePath: string | null
|
||||
onFileClick: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${node.type === 'file' && node.path === activeFilePath ? 'active' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
role="treeitem"
|
||||
onClick={() => {
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'dir' ? (
|
||||
<>
|
||||
<span className={`tree-arrow ${expandedDirs.includes(node.path) ? 'expanded' : ''}`}>
|
||||
<ChevronRight size={10} />
|
||||
</span>
|
||||
<span className="tree-icon">
|
||||
<Folder size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ width: '16px', flexShrink: 0 }} />
|
||||
<span className="tree-icon">
|
||||
<File size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tree-name">{node.name}</span>
|
||||
</div>
|
||||
{node.type === 'dir' && expandedDirs.includes(node.path) && node.children && (
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
FileTree.displayName = 'FileTree'
|
||||
@@ -1,18 +1,34 @@
|
||||
import React from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
export function StatusBar() {
|
||||
// B-04: 直接选择数据而非函数引用,确保响应式更新
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tab = tabs.find(t => t.id === activeTabId) ?? null
|
||||
export const StatusBar = React.memo(function StatusBar() {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const loadingStates = useEditorStore(s => s.loadingStates)
|
||||
|
||||
// 判断是否有任何加载状态激活
|
||||
const hasLoading = Object.values(loadingStates).some(Boolean)
|
||||
const loadingLabel = loadingStates['file-open']
|
||||
? '正在打开文件...'
|
||||
: loadingStates['dir-load']
|
||||
? '正在加载目录...'
|
||||
: loadingStates['markdown-render']
|
||||
? '正在渲染...'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div id="statusbar" role="status" aria-live="polite">
|
||||
<div className="status-left">
|
||||
{hasLoading && (
|
||||
<span className="status-loading" aria-busy="true">
|
||||
<LoadingSpinner size="small" />
|
||||
<span>{loadingLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
<span id="status-text">
|
||||
{tab ? (tab.filePath ? getFileName(tab.filePath) : '未命名') : '就绪'}
|
||||
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-right">
|
||||
@@ -22,6 +38,6 @@ export function StatusBar() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
StatusBar.displayName = 'StatusBar'
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
@@ -22,6 +24,7 @@ export function TabBar() {
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
@@ -70,15 +73,21 @@ export function TabBar() {
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab])
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
@@ -98,47 +107,71 @@ export function TabBar() {
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab])
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(() => {
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs])
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(() => {
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs])
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(() => {
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight])
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
@@ -148,64 +181,72 @@ export function TabBar() {
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" />
|
||||
<div className="tab-context-item" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,103 @@
|
||||
interface ToastProps {
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
/** Toast 类型 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
/** 单条 Toast 数据 */
|
||||
export interface ToastItem {
|
||||
id: string
|
||||
message: string
|
||||
type: ToastType
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function Toast({ message }: ToastProps) {
|
||||
/** Toast 容器组件属性 */
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastItem[]
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
/** 单条 Toast 组件 */
|
||||
const SingleToast = React.memo(function SingleToast({
|
||||
toast,
|
||||
onDismiss
|
||||
}: {
|
||||
toast: ToastItem
|
||||
onDismiss: (id: string) => void
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 进入动画
|
||||
requestAnimationFrame(() => setVisible(true))
|
||||
|
||||
// 自动消失
|
||||
if (toast.duration > 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setVisible(false)
|
||||
// 等待退出动画完成后再移除
|
||||
setTimeout(() => onDismiss(toast.id), 300)
|
||||
}, toast.duration)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [toast.id, toast.duration, onDismiss])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
setVisible(false)
|
||||
setTimeout(() => onDismiss(toast.id), 300)
|
||||
}, [toast.id, onDismiss])
|
||||
|
||||
const typeIcons: Record<ToastType, string> = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
warning: '⚠',
|
||||
info: 'ℹ'
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="toast-notification" className="show">
|
||||
{message}
|
||||
<div
|
||||
className={`toast-item toast-${toast.type} ${visible ? 'toast-visible' : ''}`}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<span className="toast-icon" aria-hidden="true">
|
||||
{typeIcons[toast.type]}
|
||||
</span>
|
||||
<span className="toast-message">{toast.message}</span>
|
||||
<button
|
||||
className="toast-close"
|
||||
onClick={handleClose}
|
||||
aria-label="关闭通知"
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* UX-05: Toast 容器组件
|
||||
* 支持多条堆叠、类型区分、关闭按钮和自动消失
|
||||
*/
|
||||
export const ToastContainer = React.memo(function ToastContainer({
|
||||
toasts,
|
||||
onDismiss
|
||||
}: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="toast-container" aria-label="通知区域">
|
||||
{toasts.map(toast => (
|
||||
<SingleToast key={toast.id} toast={toast} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ToastContainer.displayName = 'ToastContainer'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToastContainer } from './Toast'
|
||||
export type { ToastType, ToastItem } from './Toast'
|
||||
@@ -11,38 +11,50 @@ interface ToolbarProps {
|
||||
onShowAbout: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
|
||||
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
|
||||
return (
|
||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||
<div className="toolbar-left">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)">
|
||||
<div className="toolbar-left" role="group" aria-label="文件操作">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
|
||||
<FolderOpen size={18} />
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)">
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
|
||||
<Save size={18} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" />
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
|
||||
<div className="toolbar-divider" role="separator" />
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('editor')}
|
||||
title="编辑 (Ctrl+1)"
|
||||
aria-label="编辑模式"
|
||||
aria-pressed={viewMode === 'editor'}
|
||||
>
|
||||
<EditMode size={18} />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+2)">
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
title="预览 (Ctrl+2)"
|
||||
aria-label="预览模式"
|
||||
aria-pressed={viewMode === 'preview'}
|
||||
>
|
||||
<PreviewMode size={18} />
|
||||
<span>预览</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题">
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
|
||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于">
|
||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||
<Info size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Toolbar.displayName = 'Toolbar'
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
@@ -9,29 +9,29 @@ interface WelcomeScreenProps {
|
||||
onOpenRecent?: (filePath: string) => void
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
|
||||
export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
|
||||
const [recentFiles, setRecentFiles] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
recentFilesRepository.getAll(10).then(files => {
|
||||
recentFilesRepository.getAll(10).then((files: string[]) => {
|
||||
setRecentFiles(files)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="welcome-screen">
|
||||
<div id="welcome-screen" role="main" aria-label="欢迎页面">
|
||||
<div className="welcome-content">
|
||||
<div className="welcome-icon">
|
||||
<div className="welcome-icon" aria-hidden="true">
|
||||
<AppIcon size={80} />
|
||||
</div>
|
||||
<h1>欢迎使用 MarkLite</h1>
|
||||
<p>一款轻量级的 Markdown 编辑器</p>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn primary" onClick={onOpen}>
|
||||
<div className="welcome-actions" role="group" aria-label="快速操作">
|
||||
<button className="welcome-btn primary" onClick={onOpen} aria-label="打开文件">
|
||||
<WelcomeFile size={20} />
|
||||
打开文件
|
||||
</button>
|
||||
<button className="welcome-btn secondary" onClick={onNew}>
|
||||
<button className="welcome-btn secondary" onClick={onNew} aria-label="新建文件">
|
||||
<WelcomeNew size={20} />
|
||||
新建文件
|
||||
</button>
|
||||
@@ -40,15 +40,17 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp
|
||||
{recentFiles.length > 0 && (
|
||||
<div className="welcome-recent">
|
||||
<h3>最近打开</h3>
|
||||
<div className="recent-list">
|
||||
{recentFiles.map(filePath => (
|
||||
<div className="recent-list" role="list" aria-label="最近打开的文件">
|
||||
{recentFiles.map((filePath: string) => (
|
||||
<button
|
||||
key={filePath}
|
||||
className="recent-item"
|
||||
onClick={() => onOpenRecent?.(filePath)}
|
||||
title={filePath}
|
||||
role="listitem"
|
||||
aria-label={`打开 ${getFileName(filePath)}`}
|
||||
>
|
||||
<span className="recent-icon">
|
||||
<span className="recent-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
@@ -62,11 +64,13 @@ export function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="welcome-tips">
|
||||
<div className="welcome-tips" role="note" aria-label="使用提示">
|
||||
<p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p>
|
||||
<p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+F 搜索 | Ctrl+1/2 视图</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
WelcomeScreen.displayName = 'WelcomeScreen'
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { db } from './schema'
|
||||
import { db, type RecentFile } from './schema'
|
||||
|
||||
export const recentFilesRepository = {
|
||||
async add(filePath: string): Promise<void> {
|
||||
const existing = await db.recentFiles.where('filePath').equals(filePath).first()
|
||||
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
|
||||
if (existing) {
|
||||
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
||||
} else {
|
||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
||||
}
|
||||
// L-06: 清理超过 50 条的旧记录
|
||||
const all = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
||||
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
||||
if (all.length > 50) {
|
||||
const toDelete = all.slice(50)
|
||||
await db.recentFiles.bulkDelete(toDelete.map(f => f.id!))
|
||||
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
|
||||
}
|
||||
},
|
||||
|
||||
async getAll(limit = 20): Promise<string[]> {
|
||||
const files = await db.recentFiles
|
||||
async getAll(limit: number = 20): Promise<string[]> {
|
||||
const files: RecentFile[] = await db.recentFiles
|
||||
.orderBy('lastOpened')
|
||||
.reverse()
|
||||
.limit(limit)
|
||||
.toArray()
|
||||
return files.map((f: { filePath: string; lastOpened: number }) => f.filePath)
|
||||
return files.map((f: RecentFile) => f.filePath)
|
||||
},
|
||||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { db, type SettingsRecord } from './schema'
|
||||
import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const settingsRepository = {
|
||||
async load(): Promise<Settings> {
|
||||
try {
|
||||
const record = await db.settings.get('default')
|
||||
const record: SettingsRecord | undefined = await db.settings.get('default')
|
||||
if (record) {
|
||||
return {
|
||||
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
|
||||
@@ -13,15 +14,15 @@ export const settingsRepository = {
|
||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
} catch (error) {
|
||||
logError('加载设置失败', error)
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
},
|
||||
|
||||
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
||||
async save(partial: Partial<Settings>): Promise<void> {
|
||||
const current = await this.load()
|
||||
const current: Settings = await this.load()
|
||||
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
||||
await db.settings.put(merged)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
|
||||
/**
|
||||
* AR-02: 自动展开到活动文件所在的目录
|
||||
*/
|
||||
export function useAutoExpandDir(activeFilePath: string | null) {
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFilePath || !rootPath) return
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
if (!norm(activeFilePath).startsWith(norm(rootPath))) return
|
||||
|
||||
const dirsToExpand: string[] = []
|
||||
let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '')
|
||||
let prev = ''
|
||||
while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) {
|
||||
prev = dir
|
||||
dirsToExpand.push(dir)
|
||||
dir = dir.replace(/[/\\][^/\\]+$/, '')
|
||||
}
|
||||
if (dirsToExpand.length > 0) expandDirs(dirsToExpand)
|
||||
}, [activeFilePath, rootPath, expandDirs])
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-01: Promise-based 确认对话框 hook
|
||||
* 替代原生 confirm(),返回 Promise<boolean>
|
||||
*/
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
resolve: null
|
||||
})
|
||||
|
||||
// 使用 ref 确保回调中能拿到最新的 resolve
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null)
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
setState({
|
||||
open: true,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
confirmLabel: options.confirmLabel,
|
||||
cancelLabel: options.cancelLabel,
|
||||
variant: options.variant,
|
||||
resolve
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resolveRef.current?.(true)
|
||||
setState(prev => ({ ...prev, open: false, resolve: null }))
|
||||
resolveRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
resolveRef.current?.(false)
|
||||
setState(prev => ({ ...prev, open: false, resolve: null }))
|
||||
resolveRef.current = null
|
||||
}, [])
|
||||
|
||||
return {
|
||||
confirm,
|
||||
confirmDialogProps: {
|
||||
open: state.open,
|
||||
title: state.title,
|
||||
message: state.message,
|
||||
confirmLabel: state.confirmLabel,
|
||||
cancelLabel: state.cancelLabel,
|
||||
variant: state.variant,
|
||||
onConfirm: handleConfirm,
|
||||
onCancel: handleCancel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { isAllowedFile } from '../lib/fileUtils'
|
||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export function useDragDrop(showToast: (msg: string) => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
@@ -15,7 +16,7 @@ export function useDragDrop(showToast: (msg: string) => void) {
|
||||
|
||||
let rejected = 0
|
||||
for (const file of Array.from(files)) {
|
||||
const filePath = (file as File & { path?: string }).path || file.name
|
||||
const filePath: string = (file as File & { path?: string }).path || file.name
|
||||
if (!isAllowedFile(filePath)) {
|
||||
rejected++
|
||||
continue
|
||||
@@ -31,12 +32,12 @@ export function useDragDrop(showToast: (msg: string) => void) {
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read file via electronAPI:', err)
|
||||
} catch (error) {
|
||||
logError('拖拽读取文件失败', error)
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
||||
const content = ev.target?.result as string
|
||||
createTab(file.name, content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import type { ToastType } from '../components/Toast/Toast'
|
||||
|
||||
/**
|
||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||
* UX-02: 添加 loading 状态指示
|
||||
*/
|
||||
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
const result = await window.electronAPI.openFile()
|
||||
if (result && 'filePath' in result) {
|
||||
createTab(result.filePath, result.content)
|
||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开文件失败', error)
|
||||
showToast('打开文件失败', 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, showToast, setLoading])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
showToast('保存失败', 'error')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
|
||||
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
|
||||
/**
|
||||
* AR-03: 文件外部修改检测
|
||||
* 使用 Zustand store 状态替代 DOM CustomEvent
|
||||
*/
|
||||
export function useFileWatch() {
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
@@ -10,10 +16,11 @@ export function useFileWatch() {
|
||||
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
|
||||
// AR-03: 直接更新 store 状态,不再派发 CustomEvent
|
||||
setExternallyModified({ filePath })
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [getActiveTab])
|
||||
}, [getActiveTab, setExternallyModified])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
|
||||
/**
|
||||
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
|
||||
* UX-02: 添加目录加载 loading 状态
|
||||
*/
|
||||
export function useFolderOperations() {
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.openFolderDialog()
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const dirPath = result.filePaths[0]
|
||||
setRootPath(dirPath)
|
||||
expandDirs([dirPath])
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||
if (dirTree.success && dirTree.tree) {
|
||||
setTree(dirTree.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) setTree(result.tree)
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}, [rootPath, setTree, setLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
return { handleOpenFolder }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
|
||||
/**
|
||||
* 主进程事件注册 hook
|
||||
* 处理菜单触发的文件打开、保存、另存为事件
|
||||
*/
|
||||
export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
const handleSaveAsRef = useRef(handleSaveAs)
|
||||
handleSaveRef.current = handleSave
|
||||
handleSaveAsRef.current = handleSaveAs
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const api = window.electronAPI
|
||||
const onOpen = (data: { filePath: string; content: string }) => {
|
||||
createTab(data.filePath, data.content)
|
||||
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
const unsub1 = api.onFileOpenInTab(onOpen)
|
||||
const unsub2 = api.onMenuSave(() => handleSaveRef.current())
|
||||
const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current())
|
||||
return () => { unsub1(); unsub2(); unsub3() }
|
||||
}, [createTab])
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
/**
|
||||
* AR-04: 设置 hook
|
||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
||||
* 仅负责视图模式的读取和保存
|
||||
*/
|
||||
export function useSettings() {
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
settingsRepository.load().then(settings => {
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
}).catch(err => console.error('Failed to load settings:', err))
|
||||
}, [setViewMode])
|
||||
|
||||
// M-10: 使用 useCallback 稳定函数引用
|
||||
const saveViewMode = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
settingsRepository.save({ viewMode: mode })
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/**
|
||||
* AR-04: 统一设置加载 hook
|
||||
* 一次性从 IndexedDB 加载所有设置,分发到各 store
|
||||
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式
|
||||
*/
|
||||
export function useSettingsInit() {
|
||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const isInitialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) return
|
||||
isInitialized.current = true
|
||||
|
||||
settingsRepository.load()
|
||||
.then((settings) => {
|
||||
// 主题
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
|
||||
setDarkMode(darkMode)
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
|
||||
// 视图模式
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
|
||||
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
||||
const sidebarStore = useSidebarStore.getState()
|
||||
if (!sidebarStore._loaded) {
|
||||
useSidebarStore.setState({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
_loaded: true
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logError('加载设置失败', error)
|
||||
})
|
||||
}, [setDarkMode, setViewMode])
|
||||
|
||||
return { isInitialized }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent): void => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = (): void => setIsResizing(false)
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
const startResize = (): void => setIsResizing(true)
|
||||
|
||||
return { sidebarRef, isResizing, startResize }
|
||||
}
|
||||
@@ -1,29 +1,21 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
|
||||
/**
|
||||
* AR-04: 主题 hook
|
||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
||||
* 仅负责主题切换时的同步和保存
|
||||
*/
|
||||
export function useTheme() {
|
||||
const darkMode = useEditorStore(s => s.darkMode)
|
||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
||||
const isInitialized = useRef(false)
|
||||
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
|
||||
|
||||
// B-05: 从 IndexedDB 加载主题设置
|
||||
// darkMode 变化时同步 class 并保存
|
||||
useEffect(() => {
|
||||
settingsRepository.load().then(settings => {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
|
||||
isInitialized.current = true
|
||||
}).catch(err => console.error('Failed to load theme settings:', err))
|
||||
}, [setDarkMode])
|
||||
|
||||
// B-05: 首次加载完成后才保存主题设置
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current) return
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
settingsRepository.save({ darkMode })
|
||||
}, [darkMode])
|
||||
|
||||
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
|
||||
|
||||
return { darkMode, toggleDarkMode }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { ToastItem, ToastType } from '../components/Toast/Toast'
|
||||
|
||||
/** 默认自动消失时间 (ms) */
|
||||
const DEFAULT_DURATION = 3000
|
||||
|
||||
/** 最大同时显示数量 */
|
||||
const MAX_TOASTS = 5
|
||||
|
||||
/**
|
||||
* UX-05: 升级版 Toast 状态管理 hook
|
||||
* 支持多条堆叠、类型区分、自动消失
|
||||
*/
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const counterRef = useRef(0)
|
||||
|
||||
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
|
||||
const id = `toast-${++counterRef.current}`
|
||||
const newToast: ToastItem = { id, message: msg, type, duration }
|
||||
|
||||
setToasts(prev => {
|
||||
const next = [...prev, newToast]
|
||||
// 超过最大数量时移除最旧的
|
||||
if (next.length > MAX_TOASTS) {
|
||||
return next.slice(next.length - MAX_TOASTS)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
setToasts([])
|
||||
}, [])
|
||||
|
||||
return { toasts, showToast, dismissToast, cleanup }
|
||||
}
|
||||
@@ -1,6 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 未保存提醒 hook
|
||||
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
|
||||
*/
|
||||
export function useUnsavedWarning(
|
||||
hasUnsaved: () => boolean,
|
||||
confirmFn?: (message: string) => Promise<boolean>
|
||||
) {
|
||||
const confirmFnRef = useRef(confirmFn)
|
||||
confirmFnRef.current = confirmFn
|
||||
|
||||
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
||||
if (confirmFnRef.current) {
|
||||
return confirmFnRef.current(message)
|
||||
}
|
||||
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
|
||||
return true
|
||||
}, [])
|
||||
|
||||
export function useUnsavedWarning(hasUnsaved: () => boolean) {
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
@@ -15,12 +33,12 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
|
||||
|
||||
const api = window.electronAPI
|
||||
|
||||
const unsubscribe = api.onConfirmClose(() => {
|
||||
const unsubscribe = api.onConfirmClose(async () => {
|
||||
if (!hasUnsaved()) {
|
||||
api.forceClose()
|
||||
return
|
||||
}
|
||||
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?')
|
||||
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
|
||||
if (shouldClose) {
|
||||
api.forceClose()
|
||||
} else {
|
||||
@@ -29,5 +47,5 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [hasUnsaved])
|
||||
}, [hasUnsaved, doConfirm])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { AppError, logError, getErrorMessage, tryAsync } from '../errorHandler'
|
||||
|
||||
describe('errorHandler', () => {
|
||||
describe('AppError', () => {
|
||||
it('should create an error with code and message', () => {
|
||||
const error = new AppError('Test error', 'TEST_CODE')
|
||||
expect(error.message).toBe('Test error')
|
||||
expect(error.code).toBe('TEST_CODE')
|
||||
expect(error.name).toBe('AppError')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('should store cause', () => {
|
||||
const cause = new Error('root cause')
|
||||
const error = new AppError('Test error', 'TEST_CODE', cause)
|
||||
expect(error.cause).toBe(cause)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logError', () => {
|
||||
it('should log error with context', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const error = new Error('test error')
|
||||
|
||||
logError('TestContext', error)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[TestContext] test error',
|
||||
error
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle non-Error values', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
logError('TestContext', 'string error')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[TestContext] string error',
|
||||
'string error'
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('should return message for AppError', () => {
|
||||
const error = new AppError('Custom error', 'CODE')
|
||||
expect(getErrorMessage(error, 'Fallback')).toBe('Custom error')
|
||||
})
|
||||
|
||||
it('should return fallback + message for standard Error', () => {
|
||||
const error = new Error('details')
|
||||
expect(getErrorMessage(error, 'Fallback')).toBe('Fallback: details')
|
||||
})
|
||||
|
||||
it('should return fallback for non-Error values', () => {
|
||||
expect(getErrorMessage('unknown', 'Fallback')).toBe('Fallback')
|
||||
})
|
||||
|
||||
it('should return fallback for null', () => {
|
||||
expect(getErrorMessage(null, 'Fallback')).toBe('Fallback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tryAsync', () => {
|
||||
it('should return result on success', async () => {
|
||||
const [result, error] = await tryAsync(async () => 42, 'Test')
|
||||
expect(result).toBe(42)
|
||||
expect(error).toBeNull()
|
||||
})
|
||||
|
||||
it('should return AppError on failure', async () => {
|
||||
const [result, error] = await tryAsync(
|
||||
async () => { throw new Error('fail') },
|
||||
'TestContext'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect(error?.message).toBe('TestContext: fail')
|
||||
expect(error?.code).toBe('TestContext')
|
||||
})
|
||||
|
||||
it('should handle non-Error thrown values', async () => {
|
||||
const [result, error] = await tryAsync(
|
||||
async () => { throw 'string error' },
|
||||
'TestContext'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect(error?.message).toBe('TestContext: string error')
|
||||
})
|
||||
|
||||
it('should preserve AppError cause', async () => {
|
||||
const originalError = new Error('original')
|
||||
const [, error] = await tryAsync(
|
||||
async () => { throw originalError },
|
||||
'Ctx'
|
||||
)
|
||||
expect(error?.cause).toBe(originalError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
||||
|
||||
describe('fileUtils', () => {
|
||||
describe('getFileName', () => {
|
||||
it('should extract filename from Unix path', () => {
|
||||
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should extract filename from Windows path', () => {
|
||||
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should return the input if it is already a filename', () => {
|
||||
expect(getFileName('file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should handle path with trailing slash', () => {
|
||||
// getFileName uses pop() || filePath, empty string is falsy so returns filePath
|
||||
expect(getFileName('/path/to/dir/')).toBe('/path/to/dir/')
|
||||
})
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return lowercase extension', () => {
|
||||
expect(getFileExtension('file.MD')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should return .markdown extension', () => {
|
||||
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should return .txt extension', () => {
|
||||
expect(getFileExtension('notes.txt')).toBe('.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for no extension', () => {
|
||||
expect(getFileExtension('Makefile')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for hidden files starting with dot', () => {
|
||||
expect(getFileExtension('.gitignore')).toBe('')
|
||||
})
|
||||
|
||||
it('should extract extension from full path', () => {
|
||||
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should extract extension from Windows path', () => {
|
||||
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should handle file with multiple dots', () => {
|
||||
expect(getFileExtension('my.file.md')).toBe('.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedFile', () => {
|
||||
it('should return true for .md files', () => {
|
||||
expect(isAllowedFile('file.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .markdown files', () => {
|
||||
expect(isAllowedFile('file.markdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .txt files', () => {
|
||||
expect(isAllowedFile('file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for uppercase extensions', () => {
|
||||
expect(isAllowedFile('file.MD')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for .js files', () => {
|
||||
expect(isAllowedFile('file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for .json files', () => {
|
||||
expect(isAllowedFile('file.json')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for files without extension', () => {
|
||||
expect(isAllowedFile('Makefile')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with full paths', () => {
|
||||
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
||||
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderMarkdown } from '../markdown'
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('should render a simple heading', async () => {
|
||||
const result = await renderMarkdown('# Hello World')
|
||||
expect(result).toContain('<h1')
|
||||
expect(result).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('should render bold text', async () => {
|
||||
const result = await renderMarkdown('**bold**')
|
||||
expect(result).toContain('<strong')
|
||||
expect(result).toContain('bold')
|
||||
})
|
||||
|
||||
it('should render italic text', async () => {
|
||||
const result = await renderMarkdown('*italic*')
|
||||
expect(result).toContain('<em')
|
||||
expect(result).toContain('italic')
|
||||
})
|
||||
|
||||
it('should render inline code', async () => {
|
||||
const result = await renderMarkdown('`code`')
|
||||
expect(result).toContain('<code')
|
||||
expect(result).toContain('code')
|
||||
})
|
||||
|
||||
it('should render code blocks with syntax highlighting', async () => {
|
||||
const md = '```javascript\nconst x = 1;\n```'
|
||||
const result = await renderMarkdown(md)
|
||||
expect(result).toContain('<pre')
|
||||
expect(result).toContain('<code')
|
||||
expect(result).toContain('const')
|
||||
})
|
||||
|
||||
it('should render links', async () => {
|
||||
const result = await renderMarkdown('[link](https://example.com)')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('https://example.com')
|
||||
expect(result).toContain('link')
|
||||
})
|
||||
|
||||
it('should render unordered lists', async () => {
|
||||
const result = await renderMarkdown('- item 1\n- item 2')
|
||||
expect(result).toContain('<ul')
|
||||
expect(result).toContain('<li')
|
||||
expect(result).toContain('item 1')
|
||||
expect(result).toContain('item 2')
|
||||
})
|
||||
|
||||
it('should render ordered lists', async () => {
|
||||
const result = await renderMarkdown('1. first\n2. second')
|
||||
expect(result).toContain('<ol')
|
||||
expect(result).toContain('first')
|
||||
expect(result).toContain('second')
|
||||
})
|
||||
|
||||
it('should render blockquotes', async () => {
|
||||
const result = await renderMarkdown('> quote')
|
||||
expect(result).toContain('<blockquote')
|
||||
expect(result).toContain('quote')
|
||||
})
|
||||
|
||||
it('should render tables via GFM', async () => {
|
||||
const md = '| A | B |\n|---|---|\n| 1 | 2 |'
|
||||
const result = await renderMarkdown(md)
|
||||
expect(result).toContain('<table')
|
||||
expect(result).toContain('<td')
|
||||
})
|
||||
|
||||
it('should render strikethrough via GFM', async () => {
|
||||
const result = await renderMarkdown('~~deleted~~')
|
||||
expect(result).toContain('<del')
|
||||
expect(result).toContain('deleted')
|
||||
})
|
||||
|
||||
it('should handle empty content', async () => {
|
||||
const result = await renderMarkdown('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return error HTML on rendering failure with invalid input', async () => {
|
||||
// unified should still handle gracefully, but verify error path works
|
||||
const result = await renderMarkdown('normal text')
|
||||
expect(result).not.toContain('渲染错误')
|
||||
})
|
||||
|
||||
it('should cache processors for same filePath', async () => {
|
||||
// Call twice with same filePath to test caching
|
||||
const result1 = await renderMarkdown('# Test', '/test/file.md')
|
||||
const result2 = await renderMarkdown('# Test 2', '/test/file.md')
|
||||
expect(result1).toContain('<h1')
|
||||
expect(result2).toContain('Test 2')
|
||||
})
|
||||
|
||||
it('should handle null filePath', async () => {
|
||||
const result = await renderMarkdown('# No File', null)
|
||||
expect(result).toContain('No File')
|
||||
})
|
||||
|
||||
it('should handle undefined filePath', async () => {
|
||||
const result = await renderMarkdown('# No File')
|
||||
expect(result).toContain('No File')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* CQ-05: 统一错误处理模块
|
||||
* 提供一致的错误处理模式,替换分散的 try-catch
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
/** 日志级别的错误处理(仅 console.error,不中断流程) */
|
||||
export function logError(context: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[${context}] ${message}`, error)
|
||||
}
|
||||
|
||||
/** 用户操作级错误处理(返回友好的错误信息给 showToast) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof AppError) return error.message
|
||||
if (error instanceof Error) return `${fallback}: ${error.message}`
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 通用 try-catch 包装器,返回 [result, error] */
|
||||
export async function tryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: string
|
||||
): Promise<[T | null, null] | [null, AppError]> {
|
||||
try {
|
||||
const result = await fn()
|
||||
return [result, null]
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return [null, new AppError(`${context}: ${message}`, context, error)]
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,8 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
return () => (tree: Root) => {
|
||||
if (!filePath) return
|
||||
|
||||
// 获取文件所在目录
|
||||
const dir = filePath.replace(/[/\\][^/\\]+$/, '')
|
||||
const sep = dir.includes('\\') ? '\\' : '/'
|
||||
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
|
||||
const sep: string = dir.includes('\\') ? '\\' : '/'
|
||||
|
||||
function visit(node: Element | Root): void {
|
||||
if (!node.children) return
|
||||
@@ -23,9 +22,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
if (child.type === 'element' && child.tagName === 'img') {
|
||||
const src = child.properties?.src as string | undefined
|
||||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
||||
// C-04: 拒绝路径遍历攻击
|
||||
if (src.includes('..')) return
|
||||
// 相对路径转绝对路径
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
||||
@@ -42,27 +39,58 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
}
|
||||
}
|
||||
|
||||
// PF-01: Markdown处理器LRU缓存
|
||||
const MAX_CACHE_SIZE = 10
|
||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||
|
||||
function buildProcessor(filePath: string | null) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
}
|
||||
})
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
}
|
||||
|
||||
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
|
||||
const key: string = filePath ?? '__null__'
|
||||
|
||||
if (processorCache.has(key)) {
|
||||
const cached = processorCache.get(key)!
|
||||
processorCache.delete(key)
|
||||
processorCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const processor = buildProcessor(filePath)
|
||||
|
||||
if (processorCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = processorCache.keys().next().value
|
||||
if (oldestKey !== undefined) {
|
||||
processorCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
processorCache.set(key, processor)
|
||||
return processor
|
||||
}
|
||||
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
try {
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
}
|
||||
})
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
|
||||
const processor = getCachedProcessor(filePath ?? null)
|
||||
const result = await processor.process(content)
|
||||
return String(result)
|
||||
} catch (e) {
|
||||
return `<p style="color:red">渲染错误: ${e instanceof Error ? e.message : String(e)}</p>`
|
||||
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useEditorStore } from '../editorStore'
|
||||
|
||||
describe('editorStore', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
})
|
||||
})
|
||||
|
||||
describe('setViewMode', () => {
|
||||
it('should set view mode to preview', () => {
|
||||
useEditorStore.getState().setViewMode('preview')
|
||||
expect(useEditorStore.getState().viewMode).toBe('preview')
|
||||
})
|
||||
|
||||
it('should set view mode to editor', () => {
|
||||
useEditorStore.setState({ viewMode: 'preview' })
|
||||
useEditorStore.getState().setViewMode('editor')
|
||||
expect(useEditorStore.getState().viewMode).toBe('editor')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setDarkMode', () => {
|
||||
it('should enable dark mode', () => {
|
||||
useEditorStore.getState().setDarkMode(true)
|
||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should disable dark mode', () => {
|
||||
useEditorStore.setState({ darkMode: true })
|
||||
useEditorStore.getState().setDarkMode(false)
|
||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleDarkMode', () => {
|
||||
it('should toggle from false to true', () => {
|
||||
useEditorStore.getState().toggleDarkMode()
|
||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle from true to false', () => {
|
||||
useEditorStore.setState({ darkMode: true })
|
||||
useEditorStore.getState().toggleDarkMode()
|
||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setExternallyModified', () => {
|
||||
it('should set externally modified info', () => {
|
||||
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
|
||||
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
|
||||
})
|
||||
|
||||
it('should clear externally modified info', () => {
|
||||
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
|
||||
useEditorStore.getState().setExternallyModified(null)
|
||||
expect(useEditorStore.getState().externallyModified).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should set loading state', () => {
|
||||
useEditorStore.getState().setLoading('file-open', true)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unset loading key', () => {
|
||||
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should update loading state to false', () => {
|
||||
useEditorStore.getState().setLoading('file-open', true)
|
||||
useEditorStore.getState().setLoading('file-open', false)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple independent loading states', () => {
|
||||
const { setLoading } = useEditorStore.getState()
|
||||
setLoading('file-open', true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
const state = useEditorStore.getState()
|
||||
expect(state.isLoading('file-open')).toBe(true)
|
||||
expect(state.isLoading('markdown-render')).toBe(true)
|
||||
|
||||
setLoading('file-open', false)
|
||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock external dependencies before importing store
|
||||
vi.mock('../../db/tabRepository', () => ({
|
||||
tabRepository: {
|
||||
loadAll: vi.fn().mockResolvedValue([]),
|
||||
loadActiveTabId: vi.fn().mockResolvedValue(null),
|
||||
saveAll: vi.fn().mockResolvedValue(undefined),
|
||||
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
|
||||
clearAll: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('nanoid', () => {
|
||||
let counter = 0
|
||||
return {
|
||||
nanoid: vi.fn(() => `test-id-${++counter}`),
|
||||
}
|
||||
})
|
||||
|
||||
import { useTabStore } from '../tabStore'
|
||||
|
||||
describe('tabStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state between tests
|
||||
useTabStore.setState({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTab', () => {
|
||||
it('should create a new tab with default values', () => {
|
||||
const tab = useTabStore.getState().createTab()
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(tab.id).toBeTruthy()
|
||||
expect(tab.filePath).toBeNull()
|
||||
expect(tab.content).toBe('')
|
||||
expect(tab.isModified).toBe(false)
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.activeTabId).toBe(tab.id)
|
||||
})
|
||||
|
||||
it('should create a tab with file path and content', () => {
|
||||
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
|
||||
|
||||
expect(tab.filePath).toBe('/test.md')
|
||||
expect(tab.content).toBe('# Hello')
|
||||
})
|
||||
|
||||
it('should switch to existing tab if same filePath is opened', () => {
|
||||
const { createTab } = useTabStore.getState()
|
||||
createTab('/test.md', '# First')
|
||||
|
||||
// Create another tab to make it active
|
||||
createTab('/other.md', '# Other')
|
||||
|
||||
// Now try to open the first file again
|
||||
const existing = createTab('/test.md', '# Updated')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(2)
|
||||
expect(state.activeTabId).toBe(existing.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTab', () => {
|
||||
it('should close a tab and update activeTabId', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
|
||||
closeTab(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab2.id)
|
||||
expect(state.activeTabId).toBe(tab2.id)
|
||||
})
|
||||
|
||||
it('should set activeTabId to null when closing last tab', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
const tab = createTab('/file1.md')
|
||||
|
||||
closeTab(tab.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(0)
|
||||
expect(state.activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle closing non-existent tab gracefully', () => {
|
||||
const { createTab, closeTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
closeTab('non-existent')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeOtherTabs', () => {
|
||||
it('should close all tabs except the specified one', () => {
|
||||
const { createTab, closeOtherTabs } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
closeOtherTabs(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeAllTabs', () => {
|
||||
it('should close all tabs', () => {
|
||||
const { createTab, closeAllTabs } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
|
||||
closeAllTabs()
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(0)
|
||||
expect(state.activeTabId).toBeNull()
|
||||
expect(state.mruStack).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTabsToRight', () => {
|
||||
it('should close tabs to the right of the specified tab', () => {
|
||||
const { createTab, closeTabsToRight } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
closeTabsToRight(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs).toHaveLength(1)
|
||||
expect(state.tabs[0].id).toBe(tab1.id)
|
||||
})
|
||||
|
||||
it('should keep active tab if it is in the remaining set', () => {
|
||||
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
createTab('/file2.md')
|
||||
createTab('/file3.md')
|
||||
|
||||
switchToTab(tab1.id)
|
||||
closeTabsToRight(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchToTab', () => {
|
||||
it('should switch active tab and update MRU stack', () => {
|
||||
const { createTab, switchToTab } = useTabStore.getState()
|
||||
const tab1 = createTab('/file1.md')
|
||||
const tab2 = createTab('/file2.md')
|
||||
|
||||
switchToTab(tab1.id)
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.activeTabId).toBe(tab1.id)
|
||||
expect(state.mruStack).toContain(tab2.id)
|
||||
})
|
||||
|
||||
it('should not update if switching to already active tab', () => {
|
||||
const { createTab, switchToTab } = useTabStore.getState()
|
||||
createTab('/file1.md')
|
||||
|
||||
const stateBefore = useTabStore.getState()
|
||||
switchToTab(stateBefore.activeTabId!)
|
||||
const stateAfter = useTabStore.getState()
|
||||
|
||||
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent', () => {
|
||||
it('should update content and mark as modified', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
const tab = createTab('/file.md', '# Old')
|
||||
|
||||
updateTabContent(tab.id, '# New Content')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.content).toBe('# New Content')
|
||||
expect(updated.isModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveTab', () => {
|
||||
it('should return the active tab', () => {
|
||||
const { createTab, getActiveTab } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
const active = getActiveTab()
|
||||
expect(active?.id).toBe(tab.id)
|
||||
})
|
||||
|
||||
it('should return null when no tabs exist', () => {
|
||||
const { getActiveTab } = useTabStore.getState()
|
||||
expect(getActiveTab()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setModified', () => {
|
||||
it('should set modified flag on a tab', () => {
|
||||
const { createTab, setModified } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
setModified(tab.id, true)
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.isModified).toBe(true)
|
||||
|
||||
setModified(tab.id, false)
|
||||
const state2 = useTabStore.getState()
|
||||
const updated2 = state2.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated2.isModified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabScroll', () => {
|
||||
it('should update scroll position', () => {
|
||||
const { createTab, updateTabScroll } = useTabStore.getState()
|
||||
const tab = createTab('/file.md')
|
||||
|
||||
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.scrollTop).toBe(100)
|
||||
expect(updated.selectionStart).toBe(10)
|
||||
expect(updated.selectionEnd).toBe(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,17 +4,32 @@ import type { ViewMode } from '../types/settings'
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
darkMode: boolean
|
||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||
externallyModified: { filePath: string } | null
|
||||
// UX-02: 全局加载状态
|
||||
loadingStates: Record<string, boolean>
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setDarkMode: (dark: boolean) => void
|
||||
toggleDarkMode: () => void
|
||||
setExternallyModified: (info: { filePath: string } | null) => void
|
||||
// UX-02: 加载状态管理
|
||||
setLoading: (key: string, loading: boolean) => void
|
||||
isLoading: (key: string) => boolean
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setDarkMode: (dark) => set({ darkMode: dark }),
|
||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
|
||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
|
||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
|
||||
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
||||
setLoading: (key: string, loading: boolean) => set(state => ({
|
||||
loadingStates: { ...state.loadingStates, [key]: loading }
|
||||
})),
|
||||
isLoading: (key: string) => get().loadingStates[key] ?? false
|
||||
}))
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SidebarState {
|
||||
toggleDir: (path: string) => void
|
||||
expandDirs: (paths: string[]) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
/** @deprecated AR-04: 设置由 useSettingsInit 统一加载,此方法仅作兼容保留 */
|
||||
loadFromDB: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
|
||||
sidebarWidth: 240,
|
||||
_loaded: false,
|
||||
|
||||
// 从 IndexedDB 加载 sidebar 设置
|
||||
// AR-04: 设置由 useSettingsInit 统一加载
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
@@ -42,29 +43,27 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setVisible: (visible) => {
|
||||
setVisible: (visible: boolean) => {
|
||||
set({ isVisible: visible })
|
||||
// 异步保存到 DB
|
||||
settingsRepository.save({ sidebarCollapsed: !visible })
|
||||
},
|
||||
setRootPath: (path) => set({ rootPath: path }),
|
||||
setTree: (tree) => set({ tree }),
|
||||
toggleDir: (path) =>
|
||||
setRootPath: (path: string | null) => set({ rootPath: path }),
|
||||
setTree: (tree: FileNode[]) => set({ tree }),
|
||||
toggleDir: (path: string) =>
|
||||
set(state => ({
|
||||
expandedDirs: state.expandedDirs.includes(path)
|
||||
? state.expandedDirs.filter(p => p !== path)
|
||||
: [...state.expandedDirs, path]
|
||||
})),
|
||||
expandDirs: (paths) =>
|
||||
expandDirs: (paths: string[]) =>
|
||||
set(state => {
|
||||
const newDirs = paths.filter(p => !state.expandedDirs.includes(p))
|
||||
return newDirs.length > 0
|
||||
? { expandedDirs: [...state.expandedDirs, ...newDirs] }
|
||||
: state
|
||||
}),
|
||||
setSidebarWidth: (width) => {
|
||||
setSidebarWidth: (width: number) => {
|
||||
set({ sidebarWidth: width })
|
||||
// 异步保存到 DB
|
||||
settingsRepository.save({ sidebarWidth: width })
|
||||
}
|
||||
}))
|
||||
|
||||
+184
-163
@@ -3,6 +3,25 @@ import { nanoid } from 'nanoid'
|
||||
import type { Tab } from '../types/tab'
|
||||
import { tabRepository } from '../db/tabRepository'
|
||||
|
||||
// PF-08: 防抖工具函数
|
||||
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = ((...args: unknown[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn(...args)
|
||||
timer = null
|
||||
}, delay)
|
||||
}) as F & { cancel: () => void }
|
||||
debounced.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
@@ -23,50 +42,11 @@ interface TabState {
|
||||
saveToDB: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
// PF-08: 模块级防抖保存函数(500ms延迟)
|
||||
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
|
||||
|
||||
// 从 IndexedDB 加载标签页快照
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
filePath: s.filePath,
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd
|
||||
}))
|
||||
// 使用保存的 activeTabId,如果不存在则取最后一个
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({
|
||||
tabs,
|
||||
activeTabId,
|
||||
_loaded: true
|
||||
})
|
||||
} else {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tabs from DB:', err)
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
// 保存当前标签页到 IndexedDB
|
||||
saveToDB: async () => {
|
||||
function getActualSaveToDB(get: () => TabState) {
|
||||
return async () => {
|
||||
const { tabs } = get()
|
||||
if (tabs.length === 0) {
|
||||
await tabRepository.clearAll()
|
||||
@@ -84,141 +64,182 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
createTab: (filePath = null, content = '') => {
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
export const useTabStore = create<TabState>((set, get) => {
|
||||
const actualSave = getActualSaveToDB(get)
|
||||
_debouncedSaveToDB = debounce(actualSave, 500)
|
||||
|
||||
const tab: Tab = {
|
||||
id: nanoid(),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
}
|
||||
return {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
// 异步保存
|
||||
setTimeout(() => get().saveToDB(), 0)
|
||||
|
||||
return tab
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
|
||||
const newTabs = state.tabs.filter(t => t.id !== tabId)
|
||||
const newMru = state.mruStack.filter(id => id !== tabId)
|
||||
|
||||
let newActiveId = state.activeTabId
|
||||
if (state.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
filePath: s.filePath,
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd
|
||||
}))
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
} else {
|
||||
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||
if (mruCandidate) {
|
||||
newActiveId = mruCandidate
|
||||
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
set({ _loaded: true })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tabs from DB:', err)
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
saveToDB: async () => {
|
||||
if (_debouncedSaveToDB) {
|
||||
_debouncedSaveToDB()
|
||||
}
|
||||
},
|
||||
|
||||
createTab: (filePath: string | null = null, content: string = ''): Tab => {
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: newMru
|
||||
const tab: Tab = {
|
||||
id: nanoid(),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => get().saveToDB(), 0)
|
||||
},
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
set(state => {
|
||||
const target = state.tabs.find(t => t.id === tabId)
|
||||
if (!target) return state
|
||||
return { tabs: [target], activeTabId: tabId, mruStack: [] }
|
||||
})
|
||||
setTimeout(() => get().saveToDB(), 0)
|
||||
},
|
||||
_debouncedSaveToDB?.()
|
||||
return tab
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null, mruStack: [] })
|
||||
setTimeout(() => get().saveToDB(), 0)
|
||||
},
|
||||
closeTab: (tabId: string) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
|
||||
closeTabsToRight: (tabId) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
const newTabs = state.tabs.slice(0, index + 1)
|
||||
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
|
||||
}
|
||||
})
|
||||
setTimeout(() => get().saveToDB(), 0)
|
||||
},
|
||||
const newTabs = state.tabs.filter(t => t.id !== tabId)
|
||||
const newMru = state.mruStack.filter(id => id !== tabId)
|
||||
|
||||
switchToTab: (tabId) => {
|
||||
set(state => {
|
||||
if (state.activeTabId === tabId) return state
|
||||
const newMru = state.activeTabId
|
||||
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
|
||||
: state.mruStack
|
||||
return { activeTabId: tabId, mruStack: newMru }
|
||||
})
|
||||
// 异步保存 activeTabId
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
let newActiveId = state.activeTabId
|
||||
if (state.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null
|
||||
} else {
|
||||
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||
if (mruCandidate) {
|
||||
newActiveId = mruCandidate
|
||||
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTabContent: (tabId, content) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, content, isModified: true } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
|
||||
})
|
||||
|
||||
setModified: (tabId, modified) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
getActiveTab: () => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
set(state => {
|
||||
const target = state.tabs.find(t => t.id === tabId)
|
||||
if (!target) return state
|
||||
return { tabs: [target], activeTabId: tabId, mruStack: [] }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId, scroll) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
}))
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null, mruStack: [] })
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
const newTabs = state.tabs.slice(0, index + 1)
|
||||
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
|
||||
}
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
switchToTab: (tabId: string) => {
|
||||
set(state => {
|
||||
if (state.activeTabId === tabId) return state
|
||||
const newMru = state.activeTabId
|
||||
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
|
||||
: state.mruStack
|
||||
return { activeTabId: tabId, mruStack: newMru }
|
||||
})
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
|
||||
updateTabContent: (tabId: string, content: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, content, isModified: true } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId: string, modified: boolean) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
getActiveTab: (): Tab | null => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -438,9 +438,30 @@ html, body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
/* Selection — 必须匹配oneDark主题的选择器特异性才能覆盖 */
|
||||
.cm-selectionBackground {
|
||||
background: rgba(26, 115, 232, 0.2) !important;
|
||||
background: rgba(26, 115, 232, 0.25) !important;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||
.cm-editor.cm-focused .cm-selectionBackground,
|
||||
.cm-content ::selection {
|
||||
background: rgba(26, 115, 232, 0.3) !important;
|
||||
}
|
||||
|
||||
:root.dark .cm-selectionBackground {
|
||||
background: rgba(100, 160, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
:root.dark .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||
:root.dark .cm-editor.cm-focused .cm-selectionBackground,
|
||||
:root.dark .cm-content ::selection {
|
||||
background: rgba(100, 160, 255, 0.35) !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-content {
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-cursor {
|
||||
@@ -1208,3 +1229,342 @@ html, body {
|
||||
.about-close-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* ===== UX-01: ConfirmDialog ===== */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.confirm-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm-header.confirm-danger h3 { color: #d93025; }
|
||||
.confirm-header.confirm-warning h3 { color: #e37400; }
|
||||
:root.dark .confirm-header.confirm-warning h3 { color: #fdd663; }
|
||||
|
||||
.confirm-body {
|
||||
padding: 12px 24px 20px;
|
||||
}
|
||||
|
||||
.confirm-body p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 24px 20px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.confirm-btn-cancel:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.confirm-btn-danger {
|
||||
background: #d93025;
|
||||
border-color: #d93025;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn-danger:hover {
|
||||
background: #b5271d;
|
||||
}
|
||||
|
||||
.confirm-btn-warning {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn-warning:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.confirm-btn-info {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn-info:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.confirm-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===== UX-02: LoadingSpinner ===== */
|
||||
.loading-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner-svg {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-spinner-label {
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:root.dark .loading-overlay {
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Status bar loading indicator */
|
||||
.status-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-loading .loading-spinner-svg {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ===== UX-05: Toast Container (升级版) ===== */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.95);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toast-item.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Toast 类型样式 */
|
||||
.toast-success {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
border: 1px solid #a8dab5;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #fce8e6;
|
||||
color: #c5221f;
|
||||
border: 1px solid #f28b82;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: #fef7e0;
|
||||
color: #945700;
|
||||
border: 1px solid #fdd663;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:root.dark .toast-success {
|
||||
background: #0d3a1a;
|
||||
color: #81c995;
|
||||
border-color: #1e5a2e;
|
||||
}
|
||||
|
||||
:root.dark .toast-error {
|
||||
background: #3c1214;
|
||||
color: #f28b82;
|
||||
border-color: #5c1a1a;
|
||||
}
|
||||
|
||||
:root.dark .toast-warning {
|
||||
background: #3a3000;
|
||||
color: #fdd663;
|
||||
border-color: #5a4a00;
|
||||
}
|
||||
|
||||
:root.dark .toast-info {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-close:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ===== UX-07: 通用可访问性增强 ===== */
|
||||
|
||||
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
||||
button:focus-visible,
|
||||
[role="treeitem"]:focus-visible,
|
||||
[role="tab"]:focus-visible,
|
||||
[role="menuitem"]:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 确保 tab 上的关闭按钮也有焦点指示 */
|
||||
.tab-close:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 确保上下文菜单项有焦点指示 */
|
||||
.tab-context-item:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Skip to content 链接(可选,用于键盘导航) */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-tertiary: #f1f3f4;
|
||||
--text: #333333;
|
||||
--text-secondary: #5f6368;
|
||||
--text-tertiary: #9aa0a6;
|
||||
--text-secondary: #555a5e;
|
||||
--text-tertiary: #70757a;
|
||||
--border: #e1e4e8;
|
||||
--border-light: #f0f0f0;
|
||||
--code-bg: #f6f8fa;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -21,7 +21,7 @@ export interface IpcInvokeMap {
|
||||
'window:forceClose': [void, void]
|
||||
'window:cancelClose': [void, void]
|
||||
'dir:readTree': [string, ReadDirTreeResult]
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:openDialog': [void, { canceled: boolean; filePaths: string[] }]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export interface ElectronAPI {
|
||||
cancelClose: () => Promise<void>
|
||||
openExternal: (url: string) => void
|
||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||
@@ -53,8 +53,4 @@ export interface ElectronAPI {
|
||||
onConfirmClose: (callback: () => void) => Unsubscribe
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@
|
||||
"composite": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["electron.vite.config.ts"]
|
||||
"include": ["electron.vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/renderer/test-setup.ts'],
|
||||
include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/renderer/stores/**', 'src/renderer/lib/**', 'src/renderer/hooks/**'],
|
||||
exclude: ['**/__tests__/**', '**/*.d.ts'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user