Compare commits
11 Commits
fc942a2b88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8194345667 | |||
| 499cec2273 | |||
|
|
cd64110bf8 | ||
|
|
eb27e4ca2d | ||
|
|
4fca4b7143 | ||
|
|
c602573d8e | ||
|
|
fb2315a2f3 | ||
|
|
527ca5ecc5 | ||
|
|
fadee6aacf | ||
|
|
13271f11b6 | ||
|
|
c2958ebf8c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
*.tsbuildinfo
|
||||
.idea/
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -56,3 +56,17 @@
|
||||
新窗口仍能收到草稿提示;
|
||||
- 未命名保存直接落根目录、避让命名;
|
||||
- 「打开所在位置」资源管理器定位效果。
|
||||
|
||||
## 真机回修(2026-11-14 晚)
|
||||
|
||||
1. **未命名保存后文件树不刷新**:saveTab 只在新建/新建文件夹流程里刷
|
||||
树,保存落点变化(未命名首次保存、另存为)时没刷。已补:writeTextFile
|
||||
成功且路径变化时 setTreeRefresh +1。
|
||||
2. **侧栏拖拽显示禁止符号、drop 不触发**:实现本身(HTML5 DnD)在
|
||||
DOM 层测试是通过的,真机失效的根因是 Tauri 窗口默认开启
|
||||
`dragDropEnabled` 原生拖放拦截——它会吞掉 WebView 内的 HTML5 拖拽
|
||||
事件(本应用没有任何功能在用原生拖放事件)。主窗口配置
|
||||
(tauri.conf.json)与运行时新窗口(WebviewWindow 参数)均已设
|
||||
`dragDropEnabled: false`。附带好处:编辑器内 ProseMirror 的文本拖放
|
||||
同样被拦截所禁,一并恢复。若真机仍不行,下一步改指针式拖拽
|
||||
(pointer events 自绘),不再依赖 HTML5 DnD。
|
||||
|
||||
53
docs/27-Git同步.md
Normal file
53
docs/27-Git同步.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# docs/27 — 工作区 Git 同步
|
||||
|
||||
需求(2026-11-14):左侧面板给工作区文件夹加 Git 同步:可设置远程
|
||||
仓库、定时同步时间,手动/自动把文档变更同步到远程。
|
||||
|
||||
## 设计
|
||||
|
||||
- 只接管「工作区根目录本身就是仓库根」的文件夹:在父仓库的子目录里
|
||||
不启用,避免误提交别的仓库(Rust 侧用 rev-parse --show-toplevel
|
||||
与根目录比对)。
|
||||
- 同步语义固定一条链:fetch → 提交本地更改(add -A)→ 变基到
|
||||
origin/<分支> 之上 → push(无上游时 push -u)。先提交后变基,
|
||||
避免脏工作区挡住 pull。变基冲突时自动 rebase --abort 还原到同步
|
||||
前状态并报错停下,不留半成品、不强行覆盖,等用户手动处理。
|
||||
- git 身份缺失(未配 user.email)时提交自动附带
|
||||
-c user.name=new-markdown -c user.email=new-markdown@localhost,
|
||||
不让同步卡在身份配置上。
|
||||
- 凭据:走系统 git 与其凭据管理器;GIT_TERMINAL_PROMPT=0 禁止终端
|
||||
交互提问,认证失败直接把错误显示在同步条上。Windows 下子进程加
|
||||
CREATE_NO_WINDOW 防闪黑窗。
|
||||
- 实现走 git CLI(Rust run_git 封装),不引 libgit2。
|
||||
|
||||
## 界面
|
||||
|
||||
- 侧栏文件模式顶部新增「Git 同步」条:状态一句(分支 · 待提交 N 处 ·
|
||||
待推送/待拉取 · 已同步 / 未设置远程 / 未启用),右侧立即同步与
|
||||
设置按钮;同步结果/错误显示在条下方一行,不弹窗打扰。
|
||||
- 设置弹窗:远程仓库地址、(未初始化时)初始分支、定时同步间隔
|
||||
(自绘下拉:关闭/5/15/30/每小时)。未初始化时保存= git init -b
|
||||
分支 + 绑定 origin;已是仓库时只改 origin 与间隔。设置按工作区
|
||||
存偏好 gitsync:<root>。
|
||||
- 定时同步:打开工作区后 5 秒先跑一次,之后按间隔跑;同一工作区
|
||||
多窗口时用 localStorage 租约(25 秒过期、10 秒续租,与草稿认领
|
||||
同一思路)保证只有一个窗口执行,避免 index.lock 冲突;手动同步
|
||||
不受租约限制。定时同步拉到新文件后自动刷新文件树。
|
||||
|
||||
## 代码
|
||||
|
||||
- Rust:commands.rs 新增 git_status / git_init_repo / git_set_remote
|
||||
/ git_sync_now(28 条命令与 lib.rs 注册数对账一致)。
|
||||
- 前端:services/gitSync.ts(设置解析、状态文案、提交信息、租约)、
|
||||
components/GitSyncModal.tsx、Sidebar 的 GitSyncBar、App 状态与
|
||||
定时器接线(设置弹窗纳入 anyModal)。
|
||||
|
||||
## 验证
|
||||
|
||||
- vitest 117+2(新增 tests/git-sync.test.tsx 9 例:设置解析、状态
|
||||
文案、提交信息、租约互斥、同步条渲染/点击);tsc、构建通过。
|
||||
- 同步条渲染截图核验(真实 CSS,两态)。
|
||||
- 沙箱无 cargo,Rust 与真实 git 流程未编译/未实跑;真机验收重点:
|
||||
①对 Gitea 仓库初始化并绑定;②改文档后手动同步,远程出现
|
||||
「手动同步」提交;③另一处改同一文件后同步报冲突且本地不乱;
|
||||
④开定时后到点自动同步;⑤双窗口开同一工作区时只有一个在跑。
|
||||
65
docs/28-审计二轮.md
Normal file
65
docs/28-审计二轮.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# docs/28 — 审计二轮(聚焦 Git 同步,兼顾近期功能面)
|
||||
|
||||
时间:2026-11-14 晚。大王已真机测过大部分功能,本轮为代码审计:
|
||||
逐行复核 fadee6a 的 Rust 命令与前端接线,并抽查 docs/20–26 的
|
||||
关键不变量。先说结论:同步主链逻辑成立,发现 3 个中等问题已当场
|
||||
修复,4 个低问题记录待办,1 个沿袭项(CSP)状态未变。
|
||||
|
||||
## 已修复
|
||||
|
||||
### A1(中)git 命令参数的选项注入面
|
||||
分支名与远程地址来自用户输入,直接作 git 命令参数:以 `-` 开头
|
||||
会被 git 解析为选项(如给 remote add 塞 `-f` 立即抓取任意地址)。
|
||||
已在 Rust 入口加校验:validate_branch(简化 check-ref-format:
|
||||
拒绝 `-`/`.` 开头、`..`、空白/控制字符与 `~^:?*[\]`、`.lock`
|
||||
结尾、超长)与 validate_remote_url(拒绝 `-` 开头与空白/控制
|
||||
字符);git_init_repo、git_set_remote 强制走校验。
|
||||
|
||||
### A2(中)全文件夹同步的知情与兜底
|
||||
同步是 `add -A` 全文件夹提交:文件夹里的 .env、大二进制、系统
|
||||
垃圾都会被提交并推送,原始实现无任何提示与防线。两处补强:
|
||||
①init 时若无 .gitignore,种一份保守默认(.DS_Store、Thumbs.db、
|
||||
desktop.ini、*.tmp、~$*、.env、.env.*);②设置弹窗文案明示同步
|
||||
范围为文件夹全部文件(.gitignore 除外)与首次同步全量提交。
|
||||
|
||||
### A3(中)设置弹窗的陈旧状态误判
|
||||
仓库状态(gitInfo)异步加载完成前点开设置,弹窗按「未启用」
|
||||
渲染:远程地址为空、显示初始分支,保存会误走 init 流程并在
|
||||
remote add 处报错。已给打开动作加守卫:状态未就绪时不开弹窗,
|
||||
先刷新状态并在同步条给一句提示。
|
||||
|
||||
## 记录待办(本轮未改,附理由)
|
||||
|
||||
- B1(低)空仓库接入「已有内容」的远程:本地从未提交、远程分支
|
||||
已存在时,变基未出生 HEAD 的行为随 git 版本而异;失败时报错
|
||||
且可恢复(fetch 已完成,手动 git pull 一次即可)。不在同步链
|
||||
里引入 reset --hard 类危险动作,维持现状。
|
||||
- B2(低)git 子进程无超时:远程不可达(丢包型防火墙)时一次
|
||||
同步可能挂数分钟,期间显示「正在同步…」且挡后续同步。后续
|
||||
可给 run_git 加 spawn + 超时(建议 120 秒)。
|
||||
- B3(低)设置保存与进行中的同步可交错:set-url 与 fetch 各用
|
||||
当时地址,结果自洽、错误可见,不做互斥。
|
||||
- B4(低)SSH 远程首次连接的主机密钥确认:无终端环境会直接
|
||||
失败并显示错误,需先在终端连一次该主机。属预期行为。
|
||||
|
||||
## 沿袭项
|
||||
|
||||
- C1 CSP 仍为 null、Markdown 原始 HTML 的渲染面:docs/12 已
|
||||
记录未动,本轮复核状态未变,建议阶段 7 发布前单独处理。
|
||||
|
||||
## 复核通过(无问题)
|
||||
|
||||
- 同步链顺序 fetch→提交→变基→push;脏工作区不挡 pull;冲突
|
||||
rebase --abort 还原;身份缺失兜底 -c;无上游时 push -u。
|
||||
- 父仓库子目录不接管(show-toplevel 比对);命令数与注册数
|
||||
对账 28=28;Windows 子进程 CREATE_NO_WINDOW 防闪窗。
|
||||
- 前端:同步重入守卫(ref)、定时租约互斥、拉取成功刷文件树、
|
||||
工作区切换时状态复位与迟到响应丢弃、设置弹窗纳入 anyModal。
|
||||
- 抽查:chunkLoader 追加有 isCancelled 守卫;全仓无 TODO/
|
||||
FIXME/console.log 残留。
|
||||
|
||||
## 验证边界
|
||||
|
||||
- vitest 117+2 全过、tsc 与生产构建通过。
|
||||
- Rust 校验逻辑沙箱无 cargo 不可编译/单测,靠逐行复核;其实效
|
||||
待下次 Windows 构建时一并确认(校验为纯函数,无宏与平台 API)。
|
||||
200
docs/29-全项目审计.md
Normal file
200
docs/29-全项目审计.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# docs/29 — 全项目审计(性能 + 逻辑,三线并行 + App 编排层自审)
|
||||
|
||||
时间:2026-11-14 晚。范围:src/editor/ 全部、EditorPane/SourceEditor、
|
||||
src/services/、src/components/、src/hooks/、src/utils/、App.tsx 编排层、
|
||||
src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据问题**
|
||||
(已修)、中低问题一批(已修 20 余处),其余记录待办;三线均未再
|
||||
发现新的高危。搜索结果上限(每文件 20 条/共 300 文件)顺带核实存在。
|
||||
|
||||
## 已修复
|
||||
|
||||
### 高危
|
||||
|
||||
- **H1 分片载入未完成时窗格卸载,部分文档被静默回写并截断**:
|
||||
EditorPane 卸载 cleanup 无条件回写最终序列化,而实时回写路径有
|
||||
muted(载入中)守卫、唯独卸载路径漏了。载入中被保活淘汰(打开
|
||||
第 7 个标签)或关标签时,回写的是已追加的部分文本;
|
||||
syncEditorContent 对干净标签会把 savedContent 一起对齐到部分
|
||||
文本——不标脏、无提示,下一次保存即把截断内容写入磁盘。修复:
|
||||
cleanup 在 muted 时跳过回写(状态里本就是磁盘完整原文);载入
|
||||
失败路径已主动解除 muted 并如实回写,不受守卫影响。同处顺带修:
|
||||
`editor.create()` 完成回调无取消守卫,卸载后仍会把已销毁编辑器
|
||||
的 API 注册回 App → 补 cancelled 检查并销毁。回归测试见
|
||||
tests/editor-pane-unload.test.tsx(对照组);中途卸载的交错在
|
||||
jsdom 里无法自动化复现(React act 会抽干异步追加,三种卸载方式
|
||||
实测均如此),该守卫靠代码复核保证:muted 仅在追加完成/失败的
|
||||
收尾回调里解除。
|
||||
|
||||
### 性能(中)
|
||||
|
||||
- **P1 Sidebar 的 memo 被三个不稳定 props 击穿,击键时整棵文件树
|
||||
重渲染**:gitSync 内联对象、onCollapseSidebar 内联回调、随正文
|
||||
变化重建的 outline 数组。三处稳定化:gitSyncUi 用 useMemo、
|
||||
collapseSidebar 用 useCallback、非大纲模式传模块级空数组常量。
|
||||
两条审计线独立撞到同一问题,属实。
|
||||
- **P2 光标行列每次选区变化从文档头全量遍历**:Milkdown
|
||||
selectionUpdated 每事务同步分发,600K 文档光标在文末时单次上万
|
||||
节点。改为 rAF 合并:闭包缓存最新一次计算、逐帧最多算一次
|
||||
(行列与表格上下文同帧合并)。
|
||||
- **P3 图片显示解析无缓存**:同一图片出现 N 次/切标签重挂/epoch
|
||||
重建都重复读盘转 base64。App 层加(文档路径|src)→Promise 缓存,
|
||||
上限 60 条 FIFO。
|
||||
- **P4 表格工具栏滚动监听无节流**:每次 scroll 都量 DOM + setState。
|
||||
改 rAF 合并 + 位置未变不 setState。打字机模式逐键重启平滑滚动
|
||||
改 behavior:'auto' 防抖动。
|
||||
- **P5 分片切分的行偏移 O(切点数×行数)**:改行偏移前缀表 O(1) 查表。
|
||||
- **P6 导出 embed 图片串行逐张解析**:改 Promise.all 并发;
|
||||
exportHtml 高亮器初始化失败后 rejected Promise 被永久缓存、
|
||||
整会话高亮失效 → 失败时清缓存,下次导出重试。
|
||||
- **P7(Rust)list_dir 排序比较器内每次比较分配两个小写字符串**:
|
||||
改 sort_by_cached_key 预计算键。
|
||||
|
||||
### 逻辑/健壮性(中)
|
||||
|
||||
- **L1(Rust)资源复制/保存命令是同步命令且无大小上限**:
|
||||
copy_to_assets 对任意文件整读(冻 UI 线程 + 内存整载),
|
||||
save_bytes_to_assets 同样无上限。两命令改 async + spawn_blocking,
|
||||
统一 32MB 上限(与 read_file_base64 对齐)。写入侧补 64MB 上限,
|
||||
与读侧对称。
|
||||
- **L2(Rust)工作区搜索跟随 junction/符号链接逃出工作区**:去重
|
||||
只防环,指向外部的 junction 是「新目录」会被整扫、耗尽配额并
|
||||
越界读文件。scan_dir 加 canonical 包含校验,越界即跳过。顺带:
|
||||
元数据取不到时由 fail-open 改为跳过;隐藏文件与隐藏目录同口径
|
||||
跳过。
|
||||
- **L3(Rust)recovery 草稿表无任何上限**:单条可达数十 MB、条数
|
||||
无限增长、启动时全量正文过 IPC。recovery_save 加单条 10MB 上限
|
||||
(超限静默跳过,草稿是副本、.md 是正本,不报错刷屏)+ 只保留
|
||||
最近 20 条;workspaces 表同样只留最近 20 条。
|
||||
- **L4(Rust)启动期数据库失败直接 panic 且循环复现**:DB 损坏/
|
||||
磁盘异常时用户连界面都进不去。改为:打开/建表失败时把坏库
|
||||
(含 -wal/-shm)改名 `.broken-<时间戳>` 备份后新建空库继续
|
||||
启动;正文在磁盘 .md 中不受影响。连接另设 WAL + busy_timeout
|
||||
3000(多开写操作排队而非直接 SQLITE_BUSY)。
|
||||
- **L5 路径比较未做大小写归一(Windows 语义)**:pathsEqual/
|
||||
isPathInside 大小写敏感,盘符/目录大小写不一致时 DnD 守卫漏判、
|
||||
活动文件高亮消失、恢复键/Git 设置键/最近列表键同根裂键。新增
|
||||
pathCompareKey(Windows 风格路径折叠小写,POSIX 保持敏感),
|
||||
pathsEqual/isPathInside/文件树高亮/recoveryKey/Git 设置键与
|
||||
租约键/legacy recent 去重共用。注意:升级后旧恢复记录键与新键
|
||||
不一致,旧草稿可能多提示一次、随后被 20 条修剪清掉,无数据
|
||||
损失。顺带修 parentDirOf('C:/a.md') 返回盘符相对路径 'C:' →
|
||||
返回 'C:/'。
|
||||
- **L6 文件树与标签页右键菜单无视口夹取**:靠边弹出溢出屏幕、尾
|
||||
部条目够不到(编辑区菜单已有夹取,这两处漏了)。抽共享
|
||||
clampMenuPosition(utils/menuPos.ts),三处菜单共用。
|
||||
- **L7 逻辑小项打包**:①工作区搜索无乱序保护,慢的旧请求会覆盖
|
||||
新结果 → 序号守卫;②换工作区时文件树短暂显示旧目录(expanded
|
||||
按绝对路径残留)→ FileTree 按 root 加 key 重挂;③TabBar 拖拽
|
||||
dragover 以浏览器节律重复 dispatch 同一移动 → (dragId:下标)
|
||||
去重;④Dropdown 给 useDismiss 传内联 onClose,打开期间每次渲染
|
||||
重注册 5 个全局监听 → useCallback 稳定;⑤Git 租约 check-then-
|
||||
write 双窗口同启可同时取得 → 写入后回读确认持有者;⑥
|
||||
windowClaims 注册表坏条目(keys 非数组)可让启动扫描抛错 →
|
||||
prune 时整条丢弃坏条目;⑦store 的 UI 偏好 Web 模式只写不读 →
|
||||
loadUiPref Web 分支读 localStorage;⑧多行粘贴尾随换行多建一个
|
||||
空段落 → split 后丢弃末尾空项。
|
||||
|
||||
### 本轮新功能面(Git 同步)复审补强
|
||||
|
||||
- validate_branch 补:尾随 `.`、开头 `/`、连续 `//`、`@{`;
|
||||
validate_remote_url 补 ext::/fd:: 可执行 transport 黑名单
|
||||
(fetch 时会执行命令,用户自填场景威胁弱,属防患)。两函数已
|
||||
抽出在沙箱实跑断言(合法/非法各一组,全过)。
|
||||
- git_init_repo:.gitignore 播种提前到绑远程之前,部分失败不再
|
||||
留「有仓库无忽略规则」的半成品;已有 origin 改 set-url 不再
|
||||
报错。默认 .gitignore 补 `.*.tmp-*`:原子写临时文件在崩溃
|
||||
残留时原本会被 `git add -A` 提交推送。
|
||||
- git_sync_now 游离 HEAD 时不再猜分支名 "main"(会把成果推错
|
||||
地方),直接报错提示先切回分支。
|
||||
|
||||
### 一致性
|
||||
|
||||
- FindBar 与表格工具栏的原生 title 提示迁到自绘 Tooltip(跟随
|
||||
主题,与大王既定偏好对齐;路径类完整信息 title 保留);FindBar
|
||||
开关按钮补 aria-pressed。
|
||||
- GitSyncModal 补 Esc/点背幕关闭(忙碌时禁用),与 Stage5 弹窗
|
||||
范式对齐。
|
||||
- extractOutline 围栏判定改按字符+长度跟踪:四反引号围栏内的
|
||||
三反引号行不再误翻转状态(与分片切分的围栏跟踪口径一致)。
|
||||
|
||||
### 已实测澄清(非问题)
|
||||
|
||||
- **分片切分会把「列表内缩进围栏」误判为顶层切点**(审计推导项):
|
||||
已构造 >60K 含列表内嵌围栏的文档实测——旧逻辑下分片序列化与
|
||||
整篇**确实不等**,属实。修复:围栏作切点时只认顶格;回归测试
|
||||
已加入 tests/chunk-load.test.ts(关掉修复即失败,判别有效)。
|
||||
- **替换文本含换行符**:实测 replaceAll('line1\nline2') 序列化为
|
||||
段落内软换行,不拆段、不丢内容。语义与「粘贴多行建多段」不
|
||||
同,属可接受差异,维持现状。
|
||||
|
||||
## 记录待办(本轮未改,附理由)
|
||||
|
||||
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
|
||||
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
|
||||
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
|
||||
→ **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存
|
||||
弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次
|
||||
运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等
|
||||
手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实
|
||||
更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框
|
||||
盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个
|
||||
GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。
|
||||
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
|
||||
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
|
||||
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
|
||||
防抖等价物):加防抖要先理清源码模式下保存读的是哪份状态,
|
||||
贸然加会存旧文本,单独立项处理。
|
||||
- D4(低)查找索引每次 findAll 全量重建(600K 约 5MB 瞬时):
|
||||
可按 doc 引用缓存,未改。
|
||||
- D5(低)专注模式开关是模块级全局变量,多窗格/隐藏解析编辑器
|
||||
共享:目前靠各窗格写同一 App 值维持一致,「按窗格模式」时
|
||||
需改为注入式。
|
||||
- D6(低)文件树刷新时全部展开层级同时重拉(IPC burst)、拖拽
|
||||
悬停整树重渲染:TreeLevel 未 memo 化,属结构性优化,单独立项。
|
||||
- D7(低,Rust)git_status 串行拉起 6–7 个 git 子进程(每次约
|
||||
百毫秒级):可改 `git status --porcelain=v2 --branch` 一次拿
|
||||
全,解析重写风险大于当前收益,未改。
|
||||
- D8(低,Rust)rename_path:仅大小写不同的改名被 exists 预检
|
||||
误判冲突;跨卷移动直接报错而非复制+删除。前端入口目前多在
|
||||
同盘同树内,影响有限,未改。
|
||||
- D9(低,Rust)schema 无版本号/迁移机制:今后给表加列时
|
||||
CREATE TABLE IF NOT EXISTS 不会迁移老库(配合 L4 的坏库重建,
|
||||
最坏情况是偏好重置、正文无损)。加列前须先落 user_version。
|
||||
- 沿袭:docs/28 的 B1–B4(空仓变基、git 子进程无超时、设置与
|
||||
同步交错、SSH 主机密钥)与 C1(CSP=null)状态未变。
|
||||
|
||||
## 复核通过(无问题,不展开)
|
||||
|
||||
- Rust:原子写主链(同目录临时文件 + sync_all + rename,失败
|
||||
清理)、编码探测顺序与 BOM 处理、sanitize_file_name 对
|
||||
Windows 非法字符/保留名的处理、SQL 全部参数化、搜索四重
|
||||
上限、git 同步链错误路径(变基失败先 abort 再报错)。
|
||||
- 前端:useDismiss/MenuBar 监听清理完整;windowClaims 心跳
|
||||
10s/过期 45s 与 Git 租约 10s 续租/25s TTL 配比合理;
|
||||
exportHtml 代码块替换的降序 splice 与路径重写逻辑无误;
|
||||
SourceEditor replaceAll 倒序 changes 数组经 CM6 实测正确。
|
||||
- App 编排层:外部修改检测基线在打开/保存/重载各点维护完整,
|
||||
不会误报自身写入;本轮另给 4 秒轮询加了防重叠(上轮未完跳过
|
||||
本轮)。自动保存与手动保存并发同标签经原子写 + 幂等成立,
|
||||
安全。菜单定义随击键重建属小开销,可接受。
|
||||
|
||||
## 验证边界
|
||||
|
||||
- vitest 122+2(新增:EditorPane 卸载对照、列表内嵌围栏等价、
|
||||
路径/大纲新例、recoveryKey 新契约),tsc 与生产构建通过。
|
||||
- Rust:沙箱仍无法跑完整 Tauri 构建(缺 GTK/WebKit 系统库);
|
||||
四个改动文件已过 rustfmt 语法解析,validate_branch/
|
||||
validate_remote_url 已抽出实跑断言全过;命令数与注册数对账
|
||||
28=28 不变(两个命令改 async 不影响注册)。async 化、WAL、
|
||||
坏库重建、搜索包含校验的实效待 Windows 构建与真机确认。
|
||||
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
|
||||
「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档
|
||||
是否完整」一验。
|
||||
- **勘误(2026-11-14 晚,Windows 构建回报)**:本轮 M1 与 docs/27
|
||||
的 Git 命令共 6 处用了 `tokio::task::spawn_blocking`,但工程并
|
||||
没有 tokio 直接依赖(Cargo.toml 只有 tauri 等 7 个),Windows
|
||||
构建报 E0433。已全部改为工程既有写法的
|
||||
`tauri::async_runtime::spawn_blocking`(read/write 命令一直在
|
||||
用),并全量核对源码中所有外部 crate 前缀与 Cargo.toml 依赖
|
||||
一致、无其他幻影 crate。教训:写 Rust 时新引入的 crate 路径
|
||||
必须先对 Cargo.toml 存量,沙箱无法完整编译时尤其如此。
|
||||
@@ -88,6 +88,9 @@ pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> R
|
||||
}
|
||||
|
||||
fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> {
|
||||
if content.len() > 64 * 1024 * 1024 {
|
||||
return Err("文档过大(超过 64MB),无法保存".to_string());
|
||||
}
|
||||
let target = PathBuf::from(&path);
|
||||
let dir = target
|
||||
.parent()
|
||||
@@ -129,6 +132,9 @@ fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result
|
||||
}
|
||||
|
||||
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
|
||||
/// 资源文件大小上限(与 read_file_base64 对齐)。
|
||||
const MAX_ASSET_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
|
||||
let doc = PathBuf::from(doc_path);
|
||||
let dir = doc
|
||||
@@ -248,34 +254,51 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
|
||||
}
|
||||
|
||||
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
|
||||
/// 异步 + spawn_blocking 且带大小上限:原先是同步命令整读任意文件,
|
||||
/// 大文件会冻 UI 线程且内存整载(docs/29)。
|
||||
#[tauri::command]
|
||||
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
||||
let src = PathBuf::from(&source);
|
||||
let name = sanitize_file_name(
|
||||
&src.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".into()),
|
||||
);
|
||||
let dir = assets_dir(&doc_path)?;
|
||||
let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
|
||||
let target = write_new_file(&dir, &name, &data)?;
|
||||
Ok(relative_assets_path(&doc_path, &target))
|
||||
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let src = PathBuf::from(&source);
|
||||
let name = sanitize_file_name(
|
||||
&src.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".into()),
|
||||
);
|
||||
let dir = assets_dir(&doc_path)?;
|
||||
let meta = fs::metadata(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
|
||||
if meta.len() > MAX_ASSET_BYTES {
|
||||
return Err("文件过大(超过 32MB),无法作为资源复制".to_string());
|
||||
}
|
||||
let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
|
||||
let target = write_new_file(&dir, &name, &data)?;
|
||||
Ok(relative_assets_path(&doc_path, &target))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。
|
||||
/// 审计修复:原先用 Vec<u8> 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字;
|
||||
/// 改传 base64 字符串,体积与解析开销都降一个量级。
|
||||
#[tauri::command]
|
||||
pub fn save_bytes_to_assets(
|
||||
pub async fn save_bytes_to_assets(
|
||||
doc_path: String,
|
||||
file_name: String,
|
||||
base64: String,
|
||||
) -> Result<String, String> {
|
||||
let name = sanitize_file_name(&file_name);
|
||||
let bytes = base64_decode(&base64)?;
|
||||
let dir = assets_dir(&doc_path)?;
|
||||
let target = write_new_file(&dir, &name, &bytes)?;
|
||||
Ok(relative_assets_path(&doc_path, &target))
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let name = sanitize_file_name(&file_name);
|
||||
let bytes = base64_decode(&base64)?;
|
||||
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
||||
return Err("内容过大(超过 32MB),无法保存为资源文件".to_string());
|
||||
}
|
||||
let dir = assets_dir(&doc_path)?;
|
||||
let target = write_new_file(&dir, &name, &bytes)?;
|
||||
Ok(relative_assets_path(&doc_path, &target))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
@@ -361,11 +384,8 @@ pub fn list_dir(path: String) -> Result<Vec<DirEntry>, String> {
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_dir
|
||||
.cmp(&a.is_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
// 排序键预计算一次(原比较器每次比较都分配两个小写副本,大目录下浪费明显)
|
||||
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
@@ -625,3 +645,283 @@ pub async fn workspace_search(
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------- Git 同步(工作区文件夹) ----------
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStatusInfo {
|
||||
pub is_repo: bool,
|
||||
pub branch: Option<String>,
|
||||
pub remote_url: Option<String>,
|
||||
pub dirty_count: u32,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub has_upstream: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitSyncOutcome {
|
||||
pub committed: bool,
|
||||
pub pushed: bool,
|
||||
pub pulled: bool,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 分支名校验(简化版 check-ref-format):用户输入会作为 git 命令参数,
|
||||
/// 以 - 开头会被解析成选项(选项注入),其余非法形态 git 自己也会拒绝。
|
||||
fn validate_branch(branch: &str) -> Result<String, String> {
|
||||
let b = branch.trim();
|
||||
if b.is_empty() {
|
||||
return Ok("main".to_string());
|
||||
}
|
||||
let bad = b.len() > 100
|
||||
|| b.starts_with('-')
|
||||
|| b.starts_with('.')
|
||||
|| b.starts_with('/')
|
||||
|| b.ends_with('/')
|
||||
|| b.ends_with('.')
|
||||
|| b.ends_with(".lock")
|
||||
|| b.contains("..")
|
||||
|| b.contains("//")
|
||||
|| b.contains("@{")
|
||||
|| b.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\]".contains(c));
|
||||
if bad {
|
||||
return Err(format!("分支名不合法:{b}"));
|
||||
}
|
||||
Ok(b.to_string())
|
||||
}
|
||||
|
||||
/// 远程地址校验:不得以 - 开头(选项注入)、不得含空白/控制字符。
|
||||
fn validate_remote_url(url: &str) -> Result<String, String> {
|
||||
let u = url.trim();
|
||||
if u.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
// ext::/fd:: 是可执行 transport(fetch 时会跑命令),一律拒绝
|
||||
let lower = u.to_lowercase();
|
||||
if u.len() > 2000
|
||||
|| u.starts_with('-')
|
||||
|| lower.starts_with("ext::")
|
||||
|| lower.starts_with("fd::")
|
||||
|| u.chars().any(|c| c.is_whitespace() || c.is_control())
|
||||
{
|
||||
return Err("远程仓库地址不合法".to_string());
|
||||
}
|
||||
Ok(u.to_string())
|
||||
}
|
||||
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_TERMINAL_PROMPT", "0");
|
||||
// GUI 进程调控制台程序会闪黑窗,Windows 下隐藏
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
let out = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("无法运行 git(请确认已安装 Git):{e}"))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
return Err(stderr);
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return Err(stdout);
|
||||
}
|
||||
Err("git 命令执行失败".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn git_status_inner(root: &str) -> GitStatusInfo {
|
||||
let empty = GitStatusInfo {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
remote_url: None,
|
||||
dirty_count: 0,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_upstream: false,
|
||||
};
|
||||
let dir = Path::new(root);
|
||||
if run_git(dir, &["rev-parse", "--is-inside-work-tree"]).ok().as_deref() != Some("true") {
|
||||
return empty;
|
||||
}
|
||||
// 工作区必须是仓库根:在父仓库的子目录里不接管,避免误提交别的仓库
|
||||
if let Ok(top) = run_git(dir, &["rev-parse", "--show-toplevel"]) {
|
||||
let norm = |p: &str| p.replace('\\', "/").trim_end_matches('/').to_lowercase();
|
||||
if norm(&top) != norm(root) {
|
||||
return empty;
|
||||
}
|
||||
} else {
|
||||
return empty;
|
||||
}
|
||||
let branch = run_git(dir, &["branch", "--show-current"]).ok().filter(|b| !b.is_empty());
|
||||
let remote_url = run_git(dir, &["remote", "get-url", "origin"]).ok().filter(|u| !u.is_empty());
|
||||
let dirty_count = run_git(dir, &["status", "--porcelain"])
|
||||
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count() as u32)
|
||||
.unwrap_or(0);
|
||||
let mut ahead = 0u32;
|
||||
let mut behind = 0u32;
|
||||
let has_upstream = run_git(dir, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).is_ok();
|
||||
if has_upstream {
|
||||
if let Ok(counts) = run_git(dir, &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]) {
|
||||
let mut it = counts.split_whitespace();
|
||||
behind = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
ahead = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
GitStatusInfo { is_repo: true, branch, remote_url, dirty_count, ahead, behind, has_upstream }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_status(root: String) -> GitStatusInfo {
|
||||
tauri::async_runtime::spawn_blocking(move || git_status_inner(&root))
|
||||
.await
|
||||
.unwrap_or(GitStatusInfo {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
remote_url: None,
|
||||
dirty_count: 0,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_upstream: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = Path::new(&root);
|
||||
let b = validate_branch(&branch)?;
|
||||
let url = validate_remote_url(&remote_url)?;
|
||||
run_git(dir, &["init", "-b", &b])?;
|
||||
// 同步会提交文件夹内全部未忽略文件:先种一份保守的 .gitignore
|
||||
// (系统垃圾、临时文件、.env),避免敏感/无用文件被推走;
|
||||
// .*.tmp-* 是编辑器原子写的临时文件,崩溃残留不应被同步提交。
|
||||
// 播种放在绑远程之前,任一步失败也不会留下「有仓库没忽略规则」的半成品。
|
||||
let gitignore = dir.join(".gitignore");
|
||||
if !gitignore.exists() {
|
||||
let _ = std::fs::write(
|
||||
&gitignore,
|
||||
".DS_Store\nThumbs.db\ndesktop.ini\n*.tmp\n.*.tmp-*\n~$*\n.env\n.env.*\n",
|
||||
);
|
||||
}
|
||||
if !url.is_empty() {
|
||||
let has_origin = run_git(dir, &["remote"])
|
||||
.map(|r| r.lines().any(|l| l.trim() == "origin"))
|
||||
.unwrap_or(false);
|
||||
if has_origin {
|
||||
run_git(dir, &["remote", "set-url", "origin", &url])?;
|
||||
} else {
|
||||
run_git(dir, &["remote", "add", "origin", &url])?;
|
||||
}
|
||||
}
|
||||
Ok(git_status_inner(&root))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = Path::new(&root);
|
||||
let url_owned = validate_remote_url(&remote_url)?;
|
||||
let url = url_owned.as_str();
|
||||
let has_origin = run_git(dir, &["remote"]).map(|r| r.lines().any(|l| l.trim() == "origin")).unwrap_or(false);
|
||||
if url.is_empty() {
|
||||
if has_origin {
|
||||
run_git(dir, &["remote", "remove", "origin"])?;
|
||||
}
|
||||
} else if has_origin {
|
||||
run_git(dir, &["remote", "set-url", "origin", url])?;
|
||||
} else {
|
||||
run_git(dir, &["remote", "add", "origin", url])?;
|
||||
}
|
||||
Ok(git_status_inner(&root))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = Path::new(&root);
|
||||
let st = git_status_inner(&root);
|
||||
if !st.is_repo {
|
||||
return Err("该文件夹不是 Git 仓库".to_string());
|
||||
}
|
||||
let has_remote = st.remote_url.is_some();
|
||||
if has_remote {
|
||||
run_git(dir, &["fetch", "origin"]).map_err(|e| format!("拉取远程信息失败:{e}"))?;
|
||||
}
|
||||
// 先提交本地更改,再变基到远程之上,最后推送——避免脏工作区挡住 pull
|
||||
let mut committed = false;
|
||||
if st.dirty_count > 0 {
|
||||
run_git(dir, &["add", "-A"])?;
|
||||
let mut args: Vec<&str> = Vec::new();
|
||||
let email_missing = run_git(dir, &["config", "user.email"]).map(|v| v.is_empty()).unwrap_or(true);
|
||||
if email_missing {
|
||||
args.extend(["-c", "user.name=new-markdown", "-c", "user.email=new-markdown@localhost"]);
|
||||
}
|
||||
args.extend(["commit", "-m", message.as_str()]);
|
||||
run_git(dir, &args).map_err(|e| format!("提交失败:{e}"))?;
|
||||
committed = true;
|
||||
}
|
||||
// 游离 HEAD 时 branch 为空:不猜分支名(猜 main 会把成果推错地方),直接报错
|
||||
let branch = match st.branch.clone() {
|
||||
Some(b) => b,
|
||||
None => return Err("当前处于游离 HEAD 状态,请先切回分支再同步".to_string()),
|
||||
};
|
||||
let mut pulled = false;
|
||||
if has_remote {
|
||||
let remote_ref = format!("origin/{branch}");
|
||||
let remote_exists = run_git(dir, &["rev-parse", "--verify", &remote_ref]).is_ok();
|
||||
if remote_exists {
|
||||
let before = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
|
||||
if let Err(e) = run_git(dir, &["rebase", &remote_ref]) {
|
||||
let _ = run_git(dir, &["rebase", "--abort"]);
|
||||
if e.contains("CONFLICT") || e.contains("could not apply") || e.contains("Cannot rebase") {
|
||||
return Err("同步冲突:本地与远程修改了相同内容,已取消本次同步,请在该文件夹手动处理冲突后重试。".to_string());
|
||||
}
|
||||
return Err(format!("合并远程更新失败:{e}"));
|
||||
}
|
||||
let after = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
|
||||
pulled = before != after || st.behind > 0;
|
||||
}
|
||||
if st.has_upstream {
|
||||
run_git(dir, &["push"]).map_err(|e| format!("推送失败:{e}"))?;
|
||||
} else {
|
||||
run_git(dir, &["push", "-u", "origin", &branch]).map_err(|e| format!("推送失败:{e}"))?;
|
||||
}
|
||||
}
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if pulled {
|
||||
parts.push("已拉取远程更新");
|
||||
}
|
||||
if committed {
|
||||
parts.push("已提交本地更改");
|
||||
}
|
||||
if has_remote {
|
||||
parts.push("已推送到远程");
|
||||
}
|
||||
let summary = if parts.is_empty() {
|
||||
"已是最新,没有需要同步的更改".to_string()
|
||||
} else {
|
||||
parts.join(",")
|
||||
};
|
||||
Ok(GitSyncOutcome { committed, pushed: has_remote, pulled, summary })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ use tauri::Manager;
|
||||
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
|
||||
pub struct AppDb(pub Mutex<rusqlite::Connection>);
|
||||
|
||||
/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
|
||||
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
|
||||
let _ = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get::<_, String>(0));
|
||||
conn.busy_timeout(std::time::Duration::from_millis(3000))
|
||||
.map_err(|e| e.to_string())?;
|
||||
storage::init_schema(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -15,9 +25,29 @@ pub fn run() {
|
||||
.setup(|app| {
|
||||
let data_dir = app.path().app_data_dir().expect("app data dir");
|
||||
std::fs::create_dir_all(&data_dir).expect("create app data dir");
|
||||
let conn = rusqlite::Connection::open(data_dir.join("new-markdown.db"))
|
||||
.expect("open app database");
|
||||
storage::init_schema(&conn).expect("init app database schema");
|
||||
let db_path = data_dir.join("new-markdown.db");
|
||||
let conn = match open_and_init(&db_path) {
|
||||
Ok(c) => c,
|
||||
Err(first_err) => {
|
||||
// 数据库损坏/磁盘异常时不再启动即崩:把坏库改名备份后新建空库
|
||||
// 继续启动。偏好/草稿会重置,但正文都在磁盘 .md 里,不受影响(docs/29)
|
||||
eprintln!("app database unusable ({first_err}); backing it up and recreating");
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let _ = std::fs::rename(&db_path, data_dir.join(format!("new-markdown.db.broken-{ts}")));
|
||||
let _ = std::fs::rename(
|
||||
data_dir.join("new-markdown.db-wal"),
|
||||
data_dir.join(format!("new-markdown.db-wal.broken-{ts}")),
|
||||
);
|
||||
let _ = std::fs::rename(
|
||||
data_dir.join("new-markdown.db-shm"),
|
||||
data_dir.join(format!("new-markdown.db-shm.broken-{ts}")),
|
||||
);
|
||||
open_and_init(&db_path).expect("open app database")
|
||||
}
|
||||
};
|
||||
app.manage(AppDb(Mutex::new(conn)));
|
||||
Ok(())
|
||||
})
|
||||
@@ -32,6 +62,10 @@ pub fn run() {
|
||||
commands::create_dir,
|
||||
commands::rename_path,
|
||||
commands::reveal_in_explorer,
|
||||
commands::git_status,
|
||||
commands::git_init_repo,
|
||||
commands::git_set_remote,
|
||||
commands::git_sync_now,
|
||||
commands::delete_to_trash,
|
||||
commands::db_recent_list,
|
||||
commands::db_recent_add,
|
||||
|
||||
@@ -70,6 +70,7 @@ fn scan_dir(
|
||||
depth: usize,
|
||||
needle: &str,
|
||||
case_sensitive: bool,
|
||||
root_canonical: &Path,
|
||||
state: &mut ScanState,
|
||||
out: &mut Vec<FileMatches>,
|
||||
) {
|
||||
@@ -81,6 +82,11 @@ fn scan_dir(
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
// 越界即跳过:junction/符号链接指向工作区外时,去重防环挡不住(对它来说是「新目录」),
|
||||
// 会把扫描配额耗在外部文件系统上并越界读用户文件(docs/29)
|
||||
if !canonical.starts_with(root_canonical) {
|
||||
return;
|
||||
}
|
||||
if !state.visited_dirs.insert(canonical) {
|
||||
return;
|
||||
}
|
||||
@@ -100,13 +106,20 @@ fn scan_dir(
|
||||
if name.starts_with('.') || name == "node_modules" {
|
||||
continue;
|
||||
}
|
||||
scan_dir(&path, depth + 1, needle, case_sensitive, state, out);
|
||||
scan_dir(&path, depth + 1, needle, case_sensitive, root_canonical, state, out);
|
||||
} else if is_searchable(&path) {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
state.scanned_files += 1;
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES {
|
||||
continue;
|
||||
match path.metadata() {
|
||||
Ok(meta) => {
|
||||
if meta.len() > MAX_FILE_BYTES {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 元数据取不到时按跳过处理(原先是 fail-open,会整读任意大小文件)
|
||||
Err(_) => continue,
|
||||
}
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
@@ -147,6 +160,10 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec<Fi
|
||||
let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() };
|
||||
let mut state = ScanState { visited_dirs: HashSet::new(), scanned_files: 0 };
|
||||
let mut out = Vec::new();
|
||||
scan_dir(Path::new(root), 0, &needle, case_sensitive, &mut state, &mut out);
|
||||
let root_canonical = match fs::canonicalize(root) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
scan_dir(Path::new(root), 0, &needle, case_sensitive, &root_canonical, &mut state, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -111,6 +111,12 @@ pub fn workspace_add(conn: &Connection, path: &str, opened_at: i64) -> Result<()
|
||||
params![path, opened_at],
|
||||
)
|
||||
.map_err(err)?;
|
||||
// 只留最近 20 条(与 recent 的修剪口径对齐,避免列表只增不减)
|
||||
conn.execute(
|
||||
"DELETE FROM workspaces WHERE path NOT IN (SELECT path FROM workspaces ORDER BY opened_at DESC LIMIT 20)",
|
||||
[],
|
||||
)
|
||||
.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -167,6 +173,12 @@ pub fn recovery_list(conn: &Connection) -> Result<Vec<RecoveryEntry>, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 恢复记录单条上限:草稿是正文副本(.md 才是唯一正本),超限直接跳过、
|
||||
/// 不报错——否则自动保存每 6 秒都会撞一次错误提示。
|
||||
const RECOVERY_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024;
|
||||
/// 恢复记录最多保留最近更新的条数。
|
||||
const RECOVERY_KEEP: usize = 20;
|
||||
|
||||
pub fn recovery_save(
|
||||
conn: &Connection,
|
||||
doc_key: &str,
|
||||
@@ -175,6 +187,9 @@ pub fn recovery_save(
|
||||
content: &str,
|
||||
updated_at: i64,
|
||||
) -> Result<(), String> {
|
||||
if content.len() > RECOVERY_MAX_CONTENT_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title,
|
||||
@@ -182,6 +197,11 @@ pub fn recovery_save(
|
||||
params![doc_key, path, title, content, updated_at],
|
||||
)
|
||||
.map_err(err)?;
|
||||
conn.execute(
|
||||
"DELETE FROM recovery WHERE doc_key NOT IN (SELECT doc_key FROM recovery ORDER BY updated_at DESC LIMIT ?1)",
|
||||
params![RECOVERY_KEEP as i64],
|
||||
)
|
||||
.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
|
||||
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
|
||||
247
src/App.tsx
247
src/App.tsx
@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
|
||||
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
||||
import { insertPlainText, insertLinkText } from './editor/paste'
|
||||
import { isImeComposing } from './utils/ime'
|
||||
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals'
|
||||
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal } from './components/Modals'
|
||||
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
|
||||
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
||||
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
|
||||
@@ -29,10 +29,13 @@ import {
|
||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo,
|
||||
} from './services/platform'
|
||||
import * as store from './services/store'
|
||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||
import { appWindowClaims } from './services/windowClaims'
|
||||
import { appWindowClaims, appWindowId } from './services/windowClaims'
|
||||
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
|
||||
import { GitSyncModal } from './components/GitSyncModal'
|
||||
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
|
||||
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
|
||||
import { DOC_THEMES, docThemeById } from './themes'
|
||||
@@ -70,6 +73,12 @@ function toDiskContent(content: string, lineEnding: string): string {
|
||||
return lineEnding === 'CRLF' ? content.replace(/\n/g, '\r\n') : content
|
||||
}
|
||||
|
||||
/** 编辑区图片显示解析缓存:(文档路径|src) → data URL Promise,上限 60 条 FIFO。 */
|
||||
const imageDisplayCache = new Map<string, Promise<string | null>>()
|
||||
|
||||
/** 非大纲模式下传给 Sidebar 的稳定空大纲(保住 memo,避免正文变化连带整栏重渲染)。 */
|
||||
const EMPTY_OUTLINE: never[] = []
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useReducer(tabsReducer, undefined, (): TabsState => {
|
||||
const welcome = createTab({ title: '欢迎', content: WELCOME_MD })
|
||||
@@ -78,6 +87,7 @@ export default function App() {
|
||||
const tab = activeTab(state)
|
||||
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
|
||||
|
||||
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline')
|
||||
const [recent, setRecent] = useState<string[]>(loadRecent)
|
||||
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
||||
@@ -235,6 +245,106 @@ export default function App() {
|
||||
|
||||
const tauriMode = isTauri()
|
||||
|
||||
// ---- Git 同步(工作区文件夹) ----
|
||||
const [gitInfo, setGitInfo] = useState<GitStatusInfo | null>(null)
|
||||
const [gitCfg, setGitCfg] = useState<GitSyncSettings>({ intervalMin: 0 })
|
||||
const [gitSyncing, setGitSyncing] = useState(false)
|
||||
const [gitMsg, setGitMsg] = useState<{ text: string; isError: boolean } | null>(null)
|
||||
const [gitSettingsOpen, setGitSettingsOpen] = useState(false)
|
||||
const [gitSettingsError, setGitSettingsError] = useState<string | null>(null)
|
||||
const [gitSettingsBusy, setGitSettingsBusy] = useState(false)
|
||||
const gitSyncingRef = useRef(false)
|
||||
const gitRootRef = useRef<string | null>(null)
|
||||
|
||||
const refreshGitStatus = useCallback(async (root: string) => {
|
||||
try {
|
||||
const info = await gitStatus(root)
|
||||
if (gitRootRef.current === root) setGitInfo(info)
|
||||
} catch {
|
||||
if (gitRootRef.current === root) setGitInfo(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const runGitSync = useCallback(async (auto: boolean) => {
|
||||
const root = gitRootRef.current
|
||||
if (!root || gitSyncingRef.current) return
|
||||
if (auto && !acquireGitSyncLease(root, appWindowId)) return
|
||||
gitSyncingRef.current = true
|
||||
setGitSyncing(true)
|
||||
try {
|
||||
const outcome = await gitSyncNow(root, gitSyncCommitMessage(auto))
|
||||
if (gitRootRef.current !== root) return
|
||||
setGitMsg({ text: outcome.summary, isError: false })
|
||||
if (outcome.pulled) setTreeRefresh((n) => n + 1)
|
||||
} catch (e) {
|
||||
if (gitRootRef.current === root) setGitMsg({ text: String(e), isError: true })
|
||||
} finally {
|
||||
gitSyncingRef.current = false
|
||||
setGitSyncing(false)
|
||||
if (gitRootRef.current === root) void refreshGitStatus(root)
|
||||
}
|
||||
}, [refreshGitStatus])
|
||||
const gitSyncRunnerRef = useRef(runGitSync)
|
||||
gitSyncRunnerRef.current = runGitSync
|
||||
|
||||
// 工作区切换:重读该工作区的 Git 状态与同步设置
|
||||
useEffect(() => {
|
||||
gitRootRef.current = workspaceRoot
|
||||
setGitInfo(null)
|
||||
setGitMsg(null)
|
||||
setGitCfg({ intervalMin: 0 })
|
||||
setGitSettingsOpen(false)
|
||||
if (!workspaceRoot || !tauriMode) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const cfg = await loadGitSyncSettings(workspaceRoot)
|
||||
if (!cancelled && gitRootRef.current === workspaceRoot) setGitCfg(cfg)
|
||||
})()
|
||||
void refreshGitStatus(workspaceRoot)
|
||||
return () => { cancelled = true }
|
||||
}, [workspaceRoot, tauriMode, refreshGitStatus])
|
||||
|
||||
// 定时同步:到点自动跑一次(租约保证多窗口只有一个执行);开着工作区时先续租
|
||||
useEffect(() => {
|
||||
if (!workspaceRoot || !tauriMode || gitCfg.intervalMin <= 0 || !gitInfo?.isRepo) return
|
||||
const root = workspaceRoot
|
||||
acquireGitSyncLease(root, appWindowId)
|
||||
const leaseTimer = setInterval(() => acquireGitSyncLease(root, appWindowId), 10_000)
|
||||
const first = setTimeout(() => { void gitSyncRunnerRef.current(true) }, 5_000)
|
||||
const timer = setInterval(() => { void gitSyncRunnerRef.current(true) }, gitCfg.intervalMin * 60_000)
|
||||
return () => {
|
||||
clearInterval(leaseTimer)
|
||||
clearTimeout(first)
|
||||
clearInterval(timer)
|
||||
releaseGitSyncLease(root, appWindowId)
|
||||
}
|
||||
}, [workspaceRoot, tauriMode, gitCfg.intervalMin, gitInfo?.isRepo])
|
||||
|
||||
const saveGitSettings = useCallback(async (remoteUrl: string, branch: string, intervalMin: number) => {
|
||||
const root = gitRootRef.current
|
||||
if (!root) return
|
||||
setGitSettingsBusy(true)
|
||||
setGitSettingsError(null)
|
||||
try {
|
||||
let info: GitStatusInfo | null = null
|
||||
if (!gitInfo?.isRepo) {
|
||||
info = await gitInitRepo(root, remoteUrl, branch)
|
||||
} else if (remoteUrl !== (gitInfo.remoteUrl ?? '')) {
|
||||
info = await gitSetRemote(root, remoteUrl)
|
||||
}
|
||||
await saveGitSyncSettings(root, { intervalMin })
|
||||
setGitCfg({ intervalMin })
|
||||
if (info) setGitInfo(info)
|
||||
else void refreshGitStatus(root)
|
||||
setGitSettingsOpen(false)
|
||||
setGitMsg({ text: info && !gitInfo?.isRepo ? '已初始化仓库并保存设置' : 'Git 同步设置已保存', isError: false })
|
||||
} catch (e) {
|
||||
setGitSettingsError(String(e))
|
||||
} finally {
|
||||
setGitSettingsBusy(false)
|
||||
}
|
||||
}, [gitInfo, refreshGitStatus])
|
||||
|
||||
const uiSaveTimerRef = useRef<number | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (!uiLoadedRef.current) return
|
||||
@@ -346,6 +456,7 @@ export default function App() {
|
||||
minWidth: 920,
|
||||
minHeight: 600,
|
||||
decorations: false,
|
||||
dragDropEnabled: false,
|
||||
})
|
||||
} catch (e) { setError(`打开新窗口失败:${String(e)}`) }
|
||||
}, [])
|
||||
@@ -361,11 +472,32 @@ export default function App() {
|
||||
|
||||
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder])
|
||||
|
||||
const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise<boolean> => {
|
||||
// 非 UTF-8 文档保存转码提示(docs/29 D1,用户拍板加):按路径记已确认,
|
||||
// 本次运行内同一文件只提示一次;确认后走 finishSave 完成落盘收尾。
|
||||
const encodingAckedRef = useRef<Set<string>>(new Set())
|
||||
const [encodingPrompt, setEncodingPrompt] = useState<{ target: DocTab; path: string; snapshot: string } | null>(null)
|
||||
|
||||
/** 保存落盘后的收尾:标已保存(编码如实记为 UTF-8)、刷树、更新基线、清恢复记录。 */
|
||||
const finishSave = useCallback(async (target: DocTab, path: string, snapshot: string) => {
|
||||
const oldKey = recoveryKey(target)
|
||||
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path), encoding: 'UTF-8' })
|
||||
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树
|
||||
if (target.path !== path) setTreeRefresh((n) => n + 1)
|
||||
const stat = await statFile(path)
|
||||
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
|
||||
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
|
||||
const newKey = recoveryKey({ id: target.id, path })
|
||||
for (const key of new Set([oldKey, newKey])) {
|
||||
recoveryWrittenRef.current.delete(key)
|
||||
void store.deleteRecovery(key)
|
||||
}
|
||||
pushRecent(path)
|
||||
}, [pushRecent])
|
||||
|
||||
const saveTab = useCallback(async (target: DocTab, saveAs: boolean, fromAutosave = false): Promise<boolean> => {
|
||||
// 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
|
||||
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
|
||||
const snapshot = target.content
|
||||
const oldKey = recoveryKey(target)
|
||||
try {
|
||||
if (!tauriMode) {
|
||||
downloadTextFile(`${target.title}.md`, snapshot)
|
||||
@@ -387,23 +519,35 @@ export default function App() {
|
||||
path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`))
|
||||
if (!path) return false
|
||||
}
|
||||
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
|
||||
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) })
|
||||
const stat = await statFile(path)
|
||||
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
|
||||
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
|
||||
const newKey = recoveryKey({ id: target.id, path })
|
||||
for (const key of new Set([oldKey, newKey])) {
|
||||
recoveryWrittenRef.current.delete(key)
|
||||
void store.deleteRecovery(key)
|
||||
// 非 UTF-8 文档保存会转码为 UTF-8:首次保存前提示一次(按路径记已确认)。
|
||||
// 自动保存不弹窗、也不静默转码——跳过本轮,等手动保存/关窗保存时提示。
|
||||
if (target.encoding !== 'UTF-8' && !encodingAckedRef.current.has(path)) {
|
||||
if (fromAutosave) return false
|
||||
setEncodingPrompt({ target, path, snapshot })
|
||||
return false
|
||||
}
|
||||
pushRecent(path)
|
||||
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
|
||||
await finishSave(target, path, snapshot)
|
||||
return true
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
return false
|
||||
}
|
||||
}, [tauriMode, pushRecent, workspaceRoot])
|
||||
}, [tauriMode, finishSave, workspaceRoot])
|
||||
|
||||
/** 编码提示确认:记下该路径已确认,用弹窗时冻结的快照完成落盘。 */
|
||||
const confirmEncodingSave = useCallback(async () => {
|
||||
const job = encodingPrompt
|
||||
if (!job) return
|
||||
setEncodingPrompt(null)
|
||||
encodingAckedRef.current.add(job.path)
|
||||
try {
|
||||
await writeTextFile(job.path, toDiskContent(job.snapshot, job.target.lineEnding), job.target.hadBom)
|
||||
await finishSave(job.target, job.path, job.snapshot)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}, [encodingPrompt, finishSave])
|
||||
|
||||
const requestClose = useCallback((ids: string[], after: () => void) => {
|
||||
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t))
|
||||
@@ -618,7 +762,7 @@ export default function App() {
|
||||
const timer = window.setInterval(() => {
|
||||
for (const t of stateRef.current.tabs) {
|
||||
// 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改
|
||||
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false)
|
||||
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false, true)
|
||||
}
|
||||
}, Math.max(10, ui.autosaveSeconds) * 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -627,9 +771,13 @@ export default function App() {
|
||||
// ——— 阶段 4:外部修改检测(4 秒轮询 stat) ———
|
||||
const conflictRef = useRef<string | null>(null)
|
||||
conflictRef.current = conflict?.tabId ?? null
|
||||
const externalCheckBusyRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (externalCheckBusyRef.current) return
|
||||
externalCheckBusyRef.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
for (const t of stateRef.current.tabs) {
|
||||
if (!t.path) continue
|
||||
const base = baselinesRef.current.get(t.id)
|
||||
@@ -649,6 +797,9 @@ export default function App() {
|
||||
setConflict({ tabId: t.id })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
externalCheckBusyRef.current = false
|
||||
}
|
||||
})()
|
||||
}, 4000)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -1077,6 +1228,10 @@ export default function App() {
|
||||
|
||||
/** 编辑区图片显示解析:相对/本地路径按文档目录归一化后读成 data URL(仅显示层)。 */
|
||||
const resolveDisplayImage = useCallback(async (docPath: string, src: string): Promise<string | null> => {
|
||||
const cacheKey = `${docPath}|${src}`
|
||||
const cached = imageDisplayCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) return null
|
||||
let decoded = src
|
||||
@@ -1100,6 +1255,13 @@ export default function App() {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
imageDisplayCache.set(cacheKey, promise)
|
||||
if (imageDisplayCache.size > 60) {
|
||||
const oldest = imageDisplayCache.keys().next().value
|
||||
if (oldest) imageDisplayCache.delete(oldest)
|
||||
}
|
||||
return promise
|
||||
}, [])
|
||||
|
||||
const buildExportHtml = useCallback(async (
|
||||
@@ -1232,8 +1394,8 @@ export default function App() {
|
||||
}, [tauriMode, flash])
|
||||
|
||||
anyModalRef.current = Boolean(
|
||||
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
||||
|| promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||
modal || appearanceOpen || exportOpen || pendingClose || conflict || encodingPrompt
|
||||
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||
)
|
||||
findOpenRef.current = findUi.open
|
||||
|
||||
@@ -1357,6 +1519,29 @@ export default function App() {
|
||||
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
|
||||
: ''
|
||||
|
||||
// Sidebar 是 memo 组件:gitSync 对象与收起回调必须保持引用稳定,
|
||||
// 否则每次击键的 App 重渲染都会连带整棵文件树重渲染(docs/29)
|
||||
const collapseSidebar = useCallback(() => setUi((u) => ({ ...u, sidebar: false })), [])
|
||||
const gitSyncUi = useMemo(() => (tauriMode && workspaceRoot ? {
|
||||
isRepo: Boolean(gitInfo?.isRepo),
|
||||
label: gitStatusLabel(gitInfo, gitSyncing),
|
||||
syncing: gitSyncing,
|
||||
lastMessage: gitMsg?.text ?? null,
|
||||
lastIsError: gitMsg?.isError ?? false,
|
||||
onSyncNow: () => { void runGitSync(false) },
|
||||
onOpenSettings: () => {
|
||||
// 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成
|
||||
// 未初始化(远程地址显示为空、保存走 init 流程报错)
|
||||
if (!gitInfo) {
|
||||
setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false })
|
||||
if (workspaceRoot) void refreshGitStatus(workspaceRoot)
|
||||
return
|
||||
}
|
||||
setGitSettingsError(null)
|
||||
setGitSettingsOpen(true)
|
||||
},
|
||||
} : undefined), [tauriMode, workspaceRoot, gitInfo, gitSyncing, gitMsg, runGitSync, refreshGitStatus])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TitleBar docTitle={tab?.title ?? 'new-markdown'} dirty={tab ? isDirty(tab) : false} />
|
||||
@@ -1381,12 +1566,13 @@ export default function App() {
|
||||
onSaveAsEntry={saveAsEntry}
|
||||
onExportEntry={exportEntry}
|
||||
onPrintEntry={printEntry}
|
||||
onCollapseSidebar={() => setUi((u) => ({ ...u, sidebar: false }))}
|
||||
onCollapseSidebar={collapseSidebar}
|
||||
refreshSignal={treeRefresh}
|
||||
outline={outline}
|
||||
outline={sidebarMode === 'outline' ? outline : EMPTY_OUTLINE}
|
||||
onJumpHeading={jumpHeading}
|
||||
onOpenSearchResult={openSearchResult}
|
||||
tauriMode={tauriMode}
|
||||
gitSync={gitSyncUi}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1549,6 +1735,18 @@ export default function App() {
|
||||
tauriMode={tauriMode}
|
||||
zoom={zoom}
|
||||
/>
|
||||
{gitSettingsOpen && workspaceRoot && (
|
||||
<GitSyncModal
|
||||
isRepo={Boolean(gitInfo?.isRepo)}
|
||||
branch={gitInfo?.branch ?? null}
|
||||
initialRemoteUrl={gitInfo?.remoteUrl ?? ''}
|
||||
initialIntervalMin={gitCfg.intervalMin}
|
||||
busy={gitSettingsBusy}
|
||||
error={gitSettingsError}
|
||||
onSave={(remoteUrl, branch, intervalMin) => { void saveGitSettings(remoteUrl, branch, intervalMin) }}
|
||||
onClose={() => setGitSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{appearanceOpen && (
|
||||
<AppearanceModal
|
||||
docTheme={ui.docTheme}
|
||||
@@ -1690,6 +1888,15 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
{error && <ErrorModal message={error} onClose={() => setError(null)} />}
|
||||
{encodingPrompt && (
|
||||
<EncodingConvertModal
|
||||
encoding={encodingPrompt.target.encoding}
|
||||
fileName={baseName(encodingPrompt.path)}
|
||||
onConfirm={() => { void confirmEncodingSave() }}
|
||||
onCancel={() => setEncodingPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
// 展开层经 Portal 挂到 body 并用 fixed 定位(按触发钮 rect 计算、下方空间
|
||||
// 不足时向上翻):弹窗等带 overflow 的容器里 absolute 定位会被滚动盒裁掉
|
||||
// (Git 同步设置里实测被遮挡),Portal + fixed 才能真正逃出裁剪。
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
|
||||
export interface DropdownOption { value: string; label: string }
|
||||
|
||||
interface MenuPos { left: number; top: number; width: number; maxHeight: number }
|
||||
|
||||
const ITEM_H = 31
|
||||
const MENU_PAD = 8
|
||||
const MAX_H = 264
|
||||
|
||||
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
value: string
|
||||
options: DropdownOption[]
|
||||
@@ -14,12 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<MenuPos | null>(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
useDismiss(ref, open, () => setOpen(false))
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
const dismissRefs = useMemo(() => [ref, menuRef], [])
|
||||
useDismiss(dismissRefs, open, close)
|
||||
|
||||
const current = options.find((o) => o.value === value) ?? options[0]
|
||||
|
||||
// 打开时按触发钮位置算展开层几何:优先向下,下方放不下且上方更宽裕时翻上去
|
||||
const openMenu = useCallback(() => {
|
||||
const rect = btnRef.current?.getBoundingClientRect()
|
||||
if (!rect) { setOpen(true); return }
|
||||
const est = Math.min(MAX_H, options.length * ITEM_H + MENU_PAD)
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8
|
||||
const spaceAbove = rect.top - 8
|
||||
const above = spaceBelow < est && spaceAbove > spaceBelow
|
||||
const maxHeight = Math.max(80, Math.min(MAX_H, above ? spaceAbove : spaceBelow))
|
||||
const height = Math.min(est, maxHeight)
|
||||
setPos({
|
||||
left: Math.max(4, Math.min(rect.left, window.innerWidth - rect.width - 4)),
|
||||
top: above ? Math.max(4, rect.top - height - 4) : rect.bottom + 4,
|
||||
width: rect.width,
|
||||
maxHeight,
|
||||
})
|
||||
setOpen(true)
|
||||
}, [options.length])
|
||||
|
||||
// 打开时把焦点放到选中项,便于直接上下键选行
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -28,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open ])
|
||||
|
||||
// 窗口尺寸变化时展开层位置失效,直接关闭(与菜单类组件惯例一致)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onResize = () => setOpen(false)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [open ])
|
||||
|
||||
const pick = (v: string) => { setOpen(false); onChange(v) }
|
||||
|
||||
const onMenuKey = (e: React.KeyboardEvent) => {
|
||||
@@ -45,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
<div className="dropdown" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
className="dropdown-button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={() => { if (open) setOpen(false); else openMenu() }}
|
||||
>
|
||||
<span className="dropdown-label">{current?.label ?? ''}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
|
||||
{open && pos && createPortal(
|
||||
<div
|
||||
className="dropdown-menu"
|
||||
ref={menuRef}
|
||||
onKeyDown={onMenuKey}
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight, zIndex: 200 }}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
@@ -66,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
|
||||
export type EditorMenuAction =
|
||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||
@@ -26,9 +27,7 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc
|
||||
useDismiss(ref, true, onClose)
|
||||
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式)
|
||||
const left = Math.max(4, Math.min(x, window.innerWidth - 196))
|
||||
const estHeight = sourceMode ? 236 : 388
|
||||
const top = Math.max(4, Math.min(y, window.innerHeight - estHeight - 8))
|
||||
const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388)
|
||||
return (
|
||||
<div
|
||||
className="tree-context-menu editor-context-menu"
|
||||
|
||||
@@ -70,7 +70,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
if (!wrap || !el) { setTableBar(null); return }
|
||||
const wr = wrap.getBoundingClientRect()
|
||||
const tr = el.getBoundingClientRect()
|
||||
setTableBar({ top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) })
|
||||
const next = { top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) }
|
||||
setTableBar((cur) => (cur && cur.top === next.top && cur.left === next.left ? cur : next))
|
||||
}, [])
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const initialRef = useRef(initialMarkdown)
|
||||
@@ -97,7 +98,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop
|
||||
const target = lineCenter - container.clientHeight / 2
|
||||
if (Math.abs(target - container.scrollTop) > 6) {
|
||||
container.scrollTo({ top: target, behavior: 'smooth' })
|
||||
container.scrollTo({ top: target, behavior: 'auto' })
|
||||
}
|
||||
})
|
||||
} catch { /* 视图未就绪时跳过 */ }
|
||||
@@ -232,6 +233,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
}
|
||||
editor.create()
|
||||
.then(() => {
|
||||
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
|
||||
// 注销并 destroy,这里若继续会把已销毁编辑器的 API 注册回 App
|
||||
if (cancelled) { void editor.destroy().catch(() => undefined); return }
|
||||
setReady(true)
|
||||
setFocusModeEnabled(modesRef.current.focusMode)
|
||||
try {
|
||||
@@ -267,7 +271,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
})
|
||||
.catch((e) => console.error('编辑器创建失败', e))
|
||||
const scrollParent = wrapRef.current?.closest('.editor-scroll')
|
||||
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
|
||||
let scrollRaf = 0
|
||||
const onScroll = () => {
|
||||
if (!editorObjRef.current || scrollRaf) return
|
||||
scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; updateTableBar() })
|
||||
}
|
||||
scrollParent?.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -275,9 +283,15 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
editorObjRef.current = null
|
||||
setTableBar(null)
|
||||
try {
|
||||
const finalMarkdown = getMarkdown(editor)
|
||||
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
|
||||
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
|
||||
// 分片载入未收尾(muted)时编辑器里只有部分文档:跳过最终回写。
|
||||
// 状态里存的是磁盘完整原文,回写部分序列化会把文档静默截断
|
||||
// (syncEditorContent 对干净标签会把 savedContent 一起对齐)。
|
||||
// 载入失败路径已主动解除 muted 并如实回写,不受此守卫影响。
|
||||
if (!muted) {
|
||||
const finalMarkdown = getMarkdown(editor)
|
||||
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
|
||||
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
|
||||
}
|
||||
} catch { /* 编辑器未就绪时跳过回写 */ }
|
||||
callbacksRef.current.onReady(EMPTY_API)
|
||||
editor.destroy().catch(() => undefined)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Tooltip from './Tooltip'
|
||||
import type { FindOptions } from '../editor/search'
|
||||
|
||||
interface Props {
|
||||
@@ -29,21 +30,26 @@ export default function FindBar(p: Props) {
|
||||
}, [])
|
||||
|
||||
const toggle = (key: keyof FindOptions, label: string, title: string) => (
|
||||
<button
|
||||
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
|
||||
title={title}
|
||||
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Tooltip label={title}>
|
||||
<button
|
||||
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
|
||||
aria-label={title}
|
||||
aria-pressed={Boolean(p.opts[key])}
|
||||
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="findbar">
|
||||
<div className="findbar-row">
|
||||
<button className="find-icon" title={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
|
||||
{p.showReplace ? '▾' : '▸'}
|
||||
</button>
|
||||
<Tooltip label={p.showReplace ? '收起替换' : '展开替换'}>
|
||||
<button className="find-icon" aria-label={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
|
||||
{p.showReplace ? '▾' : '▸'}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={queryRef}
|
||||
className="input find-input"
|
||||
@@ -61,9 +67,9 @@ export default function FindBar(p: Props) {
|
||||
<span className="find-count">
|
||||
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
|
||||
</span>
|
||||
<button className="find-icon" title="上一个 (Shift+Enter)" onClick={p.onPrev}>↑</button>
|
||||
<button className="find-icon" title="下一个 (Enter)" onClick={p.onNext}>↓</button>
|
||||
<button className="find-icon" title="关闭 (Esc)" onClick={p.onClose}>✕</button>
|
||||
<Tooltip label="上一个 (Shift+Enter)"><button className="find-icon" aria-label="上一个" onClick={p.onPrev}>↑</button></Tooltip>
|
||||
<Tooltip label="下一个 (Enter)"><button className="find-icon" aria-label="下一个" onClick={p.onNext}>↓</button></Tooltip>
|
||||
<Tooltip label="关闭 (Esc)"><button className="find-icon" aria-label="关闭" onClick={p.onClose}>✕</button></Tooltip>
|
||||
</div>
|
||||
{p.showReplace && (
|
||||
<div className="findbar-row">
|
||||
|
||||
87
src/components/GitSyncModal.tsx
Normal file
87
src/components/GitSyncModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// Git 同步设置:远程仓库地址、(初始化时的)分支、定时同步间隔。
|
||||
// 工作区还不是仓库时,保存=初始化仓库并绑定远程;已是仓库时只更新设置。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dropdown } from './Dropdown'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: '0', label: '关闭(只手动同步)' },
|
||||
{ value: '5', label: '每 5 分钟' },
|
||||
{ value: '15', label: '每 15 分钟' },
|
||||
{ value: '30', label: '每 30 分钟' },
|
||||
{ value: '60', label: '每小时' },
|
||||
]
|
||||
|
||||
export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialIntervalMin, busy, error, onSave, onClose }: {
|
||||
isRepo: boolean
|
||||
branch: string | null
|
||||
initialRemoteUrl: string
|
||||
initialIntervalMin: number
|
||||
busy: boolean
|
||||
error: string | null
|
||||
onSave: (remoteUrl: string, branch: string, intervalMin: number) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [busy, onClose])
|
||||
const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl)
|
||||
const [branchInput, setBranchInput] = useState('main')
|
||||
const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin))
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={() => { if (!busy) onClose() }}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Git 同步设置</h3>
|
||||
<p className="modal-note">
|
||||
同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容,
|
||||
同步会停下并提示,不会强行覆盖,需要在该文件夹手动处理。
|
||||
同步范围是文件夹内的全部文件(.gitignore 排除的除外);新建仓库时会自动生成
|
||||
一份 .gitignore,排除系统垃圾、临时文件与 .env。
|
||||
</p>
|
||||
<label className="modal-field">
|
||||
<span>远程仓库地址(origin)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={remoteUrl}
|
||||
placeholder="https://github.com/example/example.git"
|
||||
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isRepo ? (
|
||||
<p className="modal-note">当前分支:{branch ?? '(无)'}。分支切换请用 Git 工具完成,这里只管同步。</p>
|
||||
) : (
|
||||
<label className="modal-field">
|
||||
<span>初始分支</span>
|
||||
<input
|
||||
className="input"
|
||||
value={branchInput}
|
||||
onChange={(e) => setBranchInput(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="modal-field">
|
||||
<span>定时同步</span>
|
||||
<Dropdown
|
||||
value={intervalMin}
|
||||
options={INTERVAL_OPTIONS}
|
||||
onChange={setIntervalMin}
|
||||
ariaLabel="定时同步间隔"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="modal-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button onClick={onClose} disabled={busy}>取消</button>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy}
|
||||
onClick={() => onSave(remoteUrl.trim(), branchInput.trim() || 'main', Number(intervalMin) || 0)}
|
||||
>
|
||||
{busy ? '处理中…' : isRepo ? '保存设置' : '初始化并启用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,24 @@ export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function EncodingConvertModal({ encoding, fileName, onConfirm, onCancel }: {
|
||||
encoding: string
|
||||
fileName: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<Shell onCancel={onCancel}>
|
||||
<h3>文档编码将转为 UTF-8</h3>
|
||||
<p>「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。</p>
|
||||
<div className="modal-actions">
|
||||
<button onClick={onCancel}>取消保存</button>
|
||||
<button className="primary" onClick={onConfirm}>转为 UTF-8 保存</button>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
export function AboutModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Shell onCancel={onClose}>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import Tooltip from './Tooltip'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
|
||||
import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
|
||||
import type { OutlineItem } from '../utils/text'
|
||||
import { baseName } from '../utils/text'
|
||||
|
||||
/** Git 同步条所需的展示状态与动作(由 App 持有真实状态)。 */
|
||||
export interface GitSyncUi {
|
||||
isRepo: boolean
|
||||
label: string
|
||||
syncing: boolean
|
||||
lastMessage: string | null
|
||||
lastIsError: boolean
|
||||
onSyncNow: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mode: 'files' | 'outline' | 'search'
|
||||
onModeChange: (mode: 'files' | 'outline' | 'search') => void
|
||||
@@ -33,6 +45,8 @@ interface Props {
|
||||
outline: OutlineItem[]
|
||||
onJumpHeading: (item: OutlineItem) => void
|
||||
tauriMode: boolean
|
||||
/** Git 同步条(仅桌面模式且已打开文件夹时由 App 传入) */
|
||||
gitSync?: GitSyncUi
|
||||
}
|
||||
|
||||
function Sidebar(p: Props) {
|
||||
@@ -67,7 +81,10 @@ function Sidebar(p: Props) {
|
||||
))}
|
||||
</div>
|
||||
) : p.workspaceRoot ? (
|
||||
<>
|
||||
{p.gitSync && <GitSyncBar ui={p.gitSync} />}
|
||||
<FileTree
|
||||
key={p.workspaceRoot}
|
||||
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
||||
onSaveAsEntry={p.onSaveAsEntry}
|
||||
onExportEntry={p.onExportEntry}
|
||||
@@ -83,6 +100,7 @@ function Sidebar(p: Props) {
|
||||
onRevealEntry={p.onRevealEntry}
|
||||
refreshSignal={p.refreshSignal ?? 0}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="sidebar-empty center">
|
||||
<p>尚未打开文件夹。</p>
|
||||
@@ -106,6 +124,32 @@ interface TreeDnd {
|
||||
drop: (dir: string) => void
|
||||
}
|
||||
|
||||
function GitSyncBar({ ui }: { ui: GitSyncUi }) {
|
||||
return (
|
||||
<div className="gitsync">
|
||||
<div className="gitsync-bar">
|
||||
<span className="gitsync-title">Git 同步</span>
|
||||
<span className="gitsync-status">{ui.label}</span>
|
||||
<span className="gitsync-actions">
|
||||
{ui.isRepo && (
|
||||
<Tooltip label="立即同步">
|
||||
<button className="gitsync-btn" aria-label="立即同步" disabled={ui.syncing} onClick={ui.onSyncNow}>
|
||||
<svg className={ui.syncing ? 'ws-spin' : ''} width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><path d="M20.2 3.6v4h-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Git 同步设置">
|
||||
<button className="gitsync-btn" aria-label="Git 同步设置" onClick={ui.onOpenSettings}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8h10M18 8h2M4 16h4M12 16h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /><circle cx="16" cy="8" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.8" /><circle cx="10" cy="16" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.8" /></svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
{ui.lastMessage && <div className={`gitsync-msg${ui.lastIsError ? ' error' : ''}`}>{ui.lastMessage}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: {
|
||||
root: string
|
||||
activePath: string | null
|
||||
@@ -175,7 +219,7 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
|
||||
dnd={dnd}
|
||||
/>
|
||||
{menu && (
|
||||
<div className="tree-context-menu" ref={menuRef} style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="tree-context-menu" ref={menuRef} style={clampMenuPosition(menu.x, menu.y, 208, 340)} onClick={(e) => e.stopPropagation()}>
|
||||
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
|
||||
{!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>}
|
||||
{onRevealEntry && <button onClick={() => { onRevealEntry(menu.entry); setMenu(null) }}>打开所在位置</button>}
|
||||
@@ -235,7 +279,7 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && entry.path === activePath ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
|
||||
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && pathsEqual(entry.path, activePath) ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
|
||||
style={{ paddingLeft: 8 + depth * 13 }}
|
||||
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
|
||||
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}
|
||||
@@ -271,18 +315,22 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runSeqRef = useRef(0)
|
||||
const run = async () => {
|
||||
if (!root || !query.trim()) return
|
||||
const seq = ++runSeqRef.current
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
try {
|
||||
const word = query.trim()
|
||||
setResults(await workspaceSearch(root, word, caseSensitive))
|
||||
const found = await workspaceSearch(root, word, caseSensitive)
|
||||
if (runSeqRef.current !== seq) return // 已有更新的搜索,旧结果作废
|
||||
setResults(found)
|
||||
setSearchedQuery(word)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
if (runSeqRef.current === seq) setError(String(e))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
if (runSeqRef.current === seq) setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
import type { DocTab } from '../stores/tabs'
|
||||
import { isDirty } from '../stores/tabs'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove, onSaveTab, onSaveAsTab, onOpenInNewWindowTab, onExportTab, onPrintTab }: Props) {
|
||||
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const lastMoveRef = useRef<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const closeMenu = useCallback(() => setMenuFor(null), [])
|
||||
useDismiss(menuRef, menuFor !== null, closeMenu)
|
||||
@@ -46,11 +48,12 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
||||
onDragStart={() => setDragId(tab.id)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
if (dragId && dragId !== tab.id) {
|
||||
if (dragId && dragId !== tab.id && lastMoveRef.current !== `${dragId}:${index}`) {
|
||||
lastMoveRef.current = `${dragId}:${index}`
|
||||
onMove(dragId, index)
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => setDragId(null)}
|
||||
onDragEnd={() => { setDragId(null); lastMoveRef.current = null }}
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -70,7 +73,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
||||
))}
|
||||
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
|
||||
{menuFor && menuTab && (
|
||||
<div className="context-menu" ref={menuRef} style={{ left: menuFor.x, top: menuFor.y }}>
|
||||
<div className="context-menu" ref={menuRef} style={clampMenuPosition(menuFor.x, menuFor.y, 200, 330)}>
|
||||
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
|
||||
<button onClick={() => act(onSaveAsTab)}>另存为…</button>
|
||||
{onOpenInNewWindowTab && <button onClick={() => act(onOpenInNewWindowTab)}>在新窗口打开</button>}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorCommands } from '../editor/commands'
|
||||
import Tooltip from './Tooltip'
|
||||
|
||||
interface Props {
|
||||
editor: Editor
|
||||
@@ -10,7 +11,9 @@ interface Props {
|
||||
/** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */
|
||||
export default function TableToolbar({ editor, top, left }: Props) {
|
||||
const btn = (label: string, fn: () => void, title?: string) => (
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} title={title ?? label}>{label}</button>
|
||||
<Tooltip label={title ?? label}>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} aria-label={title ?? label}>{label}</button>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<div className="table-toolbar" style={{ top, left }} role="toolbar" aria-label="表格工具">
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 自绘悬浮提示:跟随当前主题(界面变量),不用浏览器原生 title 气泡。
|
||||
* 纯 CSS 悬停/聚焦显示,带一点延迟避免划过时闪烁;气泡本身不拦截鼠标。
|
||||
* 纯 CSS 显示:鼠标悬停或键盘聚焦(:focus-visible)时延迟浮现;
|
||||
* 不能用 :focus-within——鼠标点过按钮后焦点留下,气泡会赖着不走。
|
||||
* 气泡本身不拦截鼠标。
|
||||
* 子元素应自带 aria-label,气泡只作视觉呈现(aria-hidden)。
|
||||
*/
|
||||
export default function Tooltip({ label, children }: { label: string; children: ReactNode }) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export const ASYNC_LOAD_THRESHOLD = 60_000
|
||||
const CHUNK_TARGET = 24_000
|
||||
|
||||
const FENCE_RE = /^\s{0,3}(```+|~~~+)/
|
||||
/** 围栏作切点时只认顶格:列表项内缩进的围栏是列表内容,切开会改变归属(docs/29) */
|
||||
const FENCE_CUT_RE = /^(`{3,}|~{3,})/
|
||||
const HEADING_RE = /^#{1,6}\s/
|
||||
const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/
|
||||
const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/
|
||||
@@ -44,20 +46,18 @@ export function splitMarkdownChunks(markdown: string): string[] {
|
||||
if (inFence) continue
|
||||
if (line.trim() !== '' || i + 1 >= lines.length) continue
|
||||
const next = lines[i + 1]
|
||||
if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
|
||||
if (HEADING_RE.test(next) || FENCE_CUT_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
|
||||
}
|
||||
if (cuts.length === 0) return [markdown]
|
||||
// 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片
|
||||
const chunks: string[] = []
|
||||
let startLine = 0
|
||||
let startOffset = 0
|
||||
const lineOffset = (idx: number) => {
|
||||
let off = 0
|
||||
for (let i = 0; i < idx; i++) off += lines[i].length + 1
|
||||
return off
|
||||
}
|
||||
// 行偏移前缀表(原实现每个切点都从头累加,切点密集时 O(切点×行数))
|
||||
const lineOffsets: number[] = [0]
|
||||
for (let i = 0; i < lines.length; i++) lineOffsets.push(lineOffsets[i] + lines[i].length + 1)
|
||||
for (const cut of cuts) {
|
||||
const off = lineOffset(cut)
|
||||
const off = lineOffsets[cut]
|
||||
if (off - startOffset >= CHUNK_TARGET && cut > startLine) {
|
||||
chunks.push(markdown.slice(startOffset, off))
|
||||
startLine = cut
|
||||
|
||||
@@ -44,21 +44,34 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
||||
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
||||
}
|
||||
if (callbacks.onCursor || callbacks.onTableContext) {
|
||||
// 选区事件在每个事务后同步分发,而行列计算要从文档头逐块数到
|
||||
// 光标(大文档 O(光标位置)):用 rAF 合并,逐帧最多算一次(docs/29)
|
||||
let cursorRaf = 0
|
||||
let pendingRun: (() => void) | null = null
|
||||
lm.selectionUpdated((selCtx, selection) => {
|
||||
callbacks.onCursor?.(cursorFromSelection(selection))
|
||||
if (callbacks.onTableContext) {
|
||||
const inTable = selectionInTable(selection as never)
|
||||
let tableEl: HTMLElement | null = null
|
||||
if (inTable) {
|
||||
try {
|
||||
const view = selCtx.get(editorViewCtx)
|
||||
const at = view.domAtPos(selection.from)
|
||||
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
|
||||
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
|
||||
} catch { /* 定位失败时工具栏不显示 */ }
|
||||
pendingRun = () => {
|
||||
callbacks.onCursor?.(cursorFromSelection(selection))
|
||||
if (callbacks.onTableContext) {
|
||||
const inTable = selectionInTable(selection as never)
|
||||
let tableEl: HTMLElement | null = null
|
||||
if (inTable) {
|
||||
try {
|
||||
const view = selCtx.get(editorViewCtx)
|
||||
const at = view.domAtPos(selection.from)
|
||||
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
|
||||
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
|
||||
} catch { /* 定位失败时工具栏不显示 */ }
|
||||
}
|
||||
callbacks.onTableContext(inTable, tableEl)
|
||||
}
|
||||
callbacks.onTableContext(inTable, tableEl)
|
||||
}
|
||||
if (cursorRaf) return
|
||||
cursorRaf = requestAnimationFrame(() => {
|
||||
cursorRaf = 0
|
||||
const run = pendingRun
|
||||
pendingRun = null
|
||||
run?.()
|
||||
})
|
||||
})
|
||||
}
|
||||
ctx.update(uploadConfig.key, (prev: UploadOptions) => ({
|
||||
|
||||
@@ -16,6 +16,8 @@ interface ViewLike {
|
||||
* 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个 <br /> 空段)。 */
|
||||
export function insertPlainTextIntoView(view: ViewLike, text: string): boolean {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||
// 原文以换行结尾时末项是空串:丢弃,否则粘贴后会多一个空段落
|
||||
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
||||
const paragraph = view.state.schema.nodes.paragraph
|
||||
const parentType = view.state.selection.$from.parent.type.name
|
||||
if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) {
|
||||
|
||||
@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
|
||||
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
||||
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
|
||||
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
|
||||
* refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与
|
||||
* 菜单是两个分离的 DOM 子树,两边都算「内」。
|
||||
*/
|
||||
export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean, onClose: () => void) {
|
||||
export function useDismiss(
|
||||
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||
active: boolean,
|
||||
onClose: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const outside = (target: EventTarget | null) =>
|
||||
ref.current && target instanceof Node && !ref.current.contains(target)
|
||||
const list = Array.isArray(refs) ? refs : [refs]
|
||||
const outside = (target: EventTarget | null) => {
|
||||
if (!(target instanceof Node)) return false
|
||||
const containers = list.map((r) => r.current).filter(Boolean) as HTMLElement[]
|
||||
return containers.length > 0 && containers.every((el) => !el.contains(target))
|
||||
}
|
||||
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
|
||||
@@ -26,5 +36,5 @@ export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean,
|
||||
document.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('blur', onClose)
|
||||
}
|
||||
}, [ref, active, onClose])
|
||||
}, [refs, active, onClose])
|
||||
}
|
||||
|
||||
@@ -67,13 +67,14 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
|
||||
|
||||
// 图片内嵌
|
||||
if (opts.imageMode === 'embed' && opts.resolveImage) {
|
||||
for (const { node } of images) {
|
||||
// 并发解析(原先逐张串行 IPC,图片多的文档导出线性变慢)
|
||||
await Promise.all(images.map(async ({ node }) => {
|
||||
const src = node.properties?.src
|
||||
if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
|
||||
const data = await opts.resolveImage(src)
|
||||
const data = await opts.resolveImage?.(src)
|
||||
if (data) node.properties.src = data
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径,
|
||||
@@ -291,6 +292,9 @@ function loadHighlighter(): Promise<import('shiki').HighlighterCore> {
|
||||
})
|
||||
})()
|
||||
}
|
||||
// 初始化失败时不永久缓存 rejected Promise(否则整会话高亮失效):
|
||||
// 本次照常抛给调用方兜底,下次导出重新尝试初始化
|
||||
highlighterPromise.catch(() => { highlighterPromise = null })
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
|
||||
105
src/services/gitSync.ts
Normal file
105
src/services/gitSync.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Git 同步的设置与文案:设置按工作区存进偏好(key=gitsync:<root>),
|
||||
// 定时同步用 localStorage 租约保证多窗口只有一个执行(与草稿认领同一思路)。
|
||||
|
||||
import { dbPrefGet, dbPrefSet, type GitStatusInfo } from './platform'
|
||||
import { pathCompareKey } from '../utils/text'
|
||||
|
||||
export interface GitSyncSettings {
|
||||
/** 定时同步间隔(分钟);0=只手动 */
|
||||
intervalMin: number
|
||||
}
|
||||
|
||||
export const DEFAULT_GIT_SYNC_SETTINGS: GitSyncSettings = { intervalMin: 0 }
|
||||
|
||||
export function parseGitSyncSettings(raw: string | null): GitSyncSettings {
|
||||
if (!raw) return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<GitSyncSettings>
|
||||
const intervalMin = typeof obj.intervalMin === 'number' && obj.intervalMin >= 0 ? obj.intervalMin : 0
|
||||
return { intervalMin }
|
||||
} catch {
|
||||
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings> {
|
||||
try {
|
||||
return parseGitSyncSettings(await dbPrefGet(`gitsync:${pathCompareKey(root)}`))
|
||||
} catch {
|
||||
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGitSyncSettings(root: string, settings: GitSyncSettings): Promise<void> {
|
||||
try {
|
||||
await dbPrefSet(`gitsync:${pathCompareKey(root)}`, JSON.stringify(settings))
|
||||
} catch {
|
||||
// 偏好写入失败不挡同步本身
|
||||
}
|
||||
}
|
||||
|
||||
/** 侧栏同步条上的一句状态文案。 */
|
||||
export function gitStatusLabel(status: GitStatusInfo | null, syncing: boolean): string {
|
||||
if (syncing) return '正在同步…'
|
||||
if (!status) return '读取仓库状态…'
|
||||
if (!status.isRepo) return '未启用 Git 同步'
|
||||
const parts: string[] = []
|
||||
if (status.branch) parts.push(status.branch)
|
||||
if (!status.remoteUrl) parts.push('未设置远程')
|
||||
if (status.dirtyCount > 0) parts.push(`待提交 ${status.dirtyCount} 处`)
|
||||
if (status.ahead > 0) parts.push(`待推送 ${status.ahead}`)
|
||||
if (status.behind > 0) parts.push(`待拉取 ${status.behind}`)
|
||||
if (status.dirtyCount === 0 && status.ahead === 0 && status.behind === 0) parts.push('已同步')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 自动同步的提交信息(本地时间)。 */
|
||||
export function gitSyncCommitMessage(auto: boolean, now = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||
return `${auto ? '自动同步' : '手动同步'} ${stamp}(new-markdown)`
|
||||
}
|
||||
|
||||
// ---- 定时同步租约:同一工作区只让一个窗口跑定时同步 ----
|
||||
|
||||
const LEASE_PREFIX = 'nm-gitsync-lease:'
|
||||
const LEASE_TTL_MS = 25_000
|
||||
|
||||
interface Lease {
|
||||
win: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
/** 尝试取得/续租该工作区的定时同步租约;被别的窗口新鲜占用时返回 false。 */
|
||||
export function acquireGitSyncLease(root: string, windowId: string, now = Date.now()): boolean {
|
||||
try {
|
||||
const key = LEASE_PREFIX + pathCompareKey(root)
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const lease = JSON.parse(raw) as Lease
|
||||
if (lease.win !== windowId && now - lease.ts < LEASE_TTL_MS) return false
|
||||
}
|
||||
const mine: Lease = { win: windowId, ts: now }
|
||||
localStorage.setItem(key, JSON.stringify(mine))
|
||||
// 回读确认:双窗口同一瞬间都通过检查时,后写者为准,先写者回读会发现
|
||||
// 持有者不是自己,放弃本次(避免并发跑 git 撞 index.lock)
|
||||
try {
|
||||
const back = JSON.parse(localStorage.getItem(key) ?? 'null') as Lease | null
|
||||
return back?.win === windowId
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
return true // 存储不可用时不拦(单窗口场景)
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseGitSyncLease(root: string, windowId: string): void {
|
||||
try {
|
||||
const key = LEASE_PREFIX + pathCompareKey(root)
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw && (JSON.parse(raw) as Lease).win === windowId) localStorage.removeItem(key)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
@@ -311,3 +311,40 @@ export async function workspaceSearch(root: string, query: string, caseSensitive
|
||||
export async function revealInExplorer(path: string): Promise<void> {
|
||||
return invoke<void>('reveal_in_explorer', { path })
|
||||
}
|
||||
|
||||
export interface GitStatusInfo {
|
||||
isRepo: boolean
|
||||
branch: string | null
|
||||
remoteUrl: string | null
|
||||
dirtyCount: number
|
||||
ahead: number
|
||||
behind: number
|
||||
hasUpstream: boolean
|
||||
}
|
||||
|
||||
export interface GitSyncOutcome {
|
||||
committed: boolean
|
||||
pushed: boolean
|
||||
pulled: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** 工作区的 Git 仓库状态(非仓库时 isRepo=false)。 */
|
||||
export async function gitStatus(root: string): Promise<GitStatusInfo> {
|
||||
return invoke<GitStatusInfo>('git_status', { root })
|
||||
}
|
||||
|
||||
/** 在工作区初始化 Git 仓库并绑定远程(远程可为空)。 */
|
||||
export async function gitInitRepo(root: string, remoteUrl: string, branch: string): Promise<GitStatusInfo> {
|
||||
return invoke<GitStatusInfo>('git_init_repo', { root, remoteUrl, branch })
|
||||
}
|
||||
|
||||
/** 设置/清除工作区仓库的 origin 远程(空字符串=移除)。 */
|
||||
export async function gitSetRemote(root: string, remoteUrl: string): Promise<GitStatusInfo> {
|
||||
return invoke<GitStatusInfo>('git_set_remote', { root, remoteUrl })
|
||||
}
|
||||
|
||||
/** 同步一次:提交本地更改→变基拉取远程→推送。冲突时返回错误且不留半成品。 */
|
||||
export async function gitSyncNow(root: string, message: string): Promise<GitSyncOutcome> {
|
||||
return invoke<GitSyncOutcome>('git_sync_now', { root, message })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete,
|
||||
type RecoveryEntry,
|
||||
} from './platform'
|
||||
import { pathsEqual } from '../utils/text'
|
||||
|
||||
export type { RecoveryEntry }
|
||||
|
||||
@@ -52,7 +53,7 @@ export function addRecent(path: string, name: string): void {
|
||||
void dbRecentAdd(path, name, Date.now()).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const list = readLegacyRecent().filter((r) => r.path !== path)
|
||||
const list = readLegacyRecent().filter((r) => !pathsEqual(r.path, path))
|
||||
list.unshift({ path, name })
|
||||
localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10)))
|
||||
}
|
||||
@@ -62,7 +63,7 @@ export function removeRecent(path: string): void {
|
||||
void dbRecentRemove(path).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => r.path !== path)))
|
||||
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => !pathsEqual(r.path, path))))
|
||||
}
|
||||
|
||||
export function addWorkspace(path: string): void {
|
||||
@@ -90,7 +91,9 @@ export async function loadLastWorkspace(): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function loadUiPref(): Promise<string | null> {
|
||||
if (!isTauri()) return null
|
||||
if (!isTauri()) {
|
||||
try { return localStorage.getItem(LS_UI) } catch { return null }
|
||||
}
|
||||
try {
|
||||
const fromDb = await dbPrefGet('ui')
|
||||
if (fromDb) {
|
||||
|
||||
@@ -47,7 +47,10 @@ export function createWindowClaims(
|
||||
const t = now()
|
||||
const out: Record<string, ClaimEntry> = {}
|
||||
for (const [id, entry] of Object.entries(reg)) {
|
||||
if (entry && typeof entry.ts === 'number' && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
|
||||
// keys 必须是字符串数组:注册表被写坏(旧版本/外部改动)时整条丢弃,
|
||||
// 避免 liveClaimedKeys 逐字迭代字符串或对不可迭代值抛错
|
||||
const keysOk = Array.isArray(entry?.keys) && entry.keys.every((k) => typeof k === 'string')
|
||||
if (entry && typeof entry.ts === 'number' && keysOk && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -77,4 +80,7 @@ export function createWindowClaims(
|
||||
const randomWindowId = `win-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
/** 本应用窗口的认领单例(App 使用)。 */
|
||||
/** 本窗口的随机标识(草稿认领与 Git 定时同步租约共用)。 */
|
||||
export const appWindowId: string = randomWindowId
|
||||
|
||||
export const appWindowClaims: WindowClaims = createWindowClaims(randomWindowId)
|
||||
|
||||
@@ -46,7 +46,7 @@ export type TabsAction =
|
||||
| { type: 'open'; tab: DocTab }
|
||||
| { type: 'activate'; id: string }
|
||||
| { type: 'updateContent'; id: string; content: string }
|
||||
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
|
||||
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string; encoding?: string }
|
||||
| { type: 'syncEditorContent'; id: string; content: string }
|
||||
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
||||
| { type: 'setPath'; id: string; path: string; title: string }
|
||||
@@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === action.id
|
||||
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title }
|
||||
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title, encoding: action.encoding ?? t.encoding }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
||||
/* 自绘悬浮提示(Tooltip 组件):跟随主题变量,悬停/聚焦延迟浮现 */
|
||||
.tooltip-wrap { position: relative; display: inline-flex; align-items: stretch; }
|
||||
.tooltip-wrap .tooltip-bubble { position: absolute; left: 50%; bottom: calc(100% + 6px); transform: translateX(-50%) translateY(2px); background: var(--bg-editor); color: var(--text); border: 1px solid var(--border); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); font-size: 12px; font-weight: 400; line-height: 1.4; padding: 4px 8px; border-radius: 6px; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.15s ease, transform 0.15s ease; z-index: 80; }
|
||||
.tooltip-wrap:hover .tooltip-bubble, .tooltip-wrap:focus-within .tooltip-bubble { opacity: 1; transform: translateX(-50%) translateY(0); transition-delay: 0.35s; }
|
||||
.tooltip-wrap:hover .tooltip-bubble, .tooltip-wrap:has(:focus-visible) .tooltip-bubble { opacity: 1; transform: translateX(-50%) translateY(0); transition-delay: 0.35s; }
|
||||
/* 自绘下拉(Dropdown 组件):按钮对齐 .input,展开层沿用应用菜单语言 */
|
||||
.dropdown { position: relative; }
|
||||
.dropdown-button { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: 6px; padding: 5px 9px; font-size: 13px; font-family: inherit; cursor: pointer; text-align: left; }
|
||||
@@ -358,7 +358,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
||||
.dropdown-button:disabled { opacity: 0.5; cursor: default; }
|
||||
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
||||
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; }
|
||||
/* 展开层几何(fixed 定位/宽/最大高)由 Dropdown 组件内联给出:Portal 到 body 逃出弹窗等 overflow 容器的裁剪 */
|
||||
.dropdown-menu { background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
|
||||
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||
.dropdown-menu button.selected { background: var(--accent-weak); }
|
||||
@@ -416,3 +417,16 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
|
||||
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
|
||||
*::-webkit-scrollbar-corner { background: transparent; }
|
||||
|
||||
/* Git 同步条(侧栏文件模式顶部) */
|
||||
.gitsync { border-bottom: 1px solid var(--border); padding: 6px 10px; }
|
||||
.gitsync-bar { display: flex; align-items: center; gap: 8px; min-height: 26px; }
|
||||
.gitsync-title { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.gitsync-status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--text-dim); }
|
||||
.gitsync-actions { display: flex; gap: 2px; }
|
||||
.gitsync-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: 6px; background: transparent; color: var(--text-dim); cursor: pointer; }
|
||||
.gitsync-btn:hover:not(:disabled) { background: var(--hover); color: var(--text); }
|
||||
.gitsync-btn:disabled { opacity: .5; cursor: default; }
|
||||
.gitsync-msg { margin-top: 4px; font-size: 11.5px; line-height: 1.45; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||
.gitsync-msg.error { color: #cf5b52; }
|
||||
.modal-error { color: #cf5b52; font-size: 12px; line-height: 1.5; margin: 8px 0 0; }
|
||||
|
||||
7
src/utils/menuPos.ts
Normal file
7
src/utils/menuPos.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** 右键菜单位置夹取:不让菜单溢出窗口右/下边(尺寸为估算值,调用方按条目数给)。 */
|
||||
export function clampMenuPosition(x: number, y: number, width: number, estHeight: number): { left: number; top: number } {
|
||||
return {
|
||||
left: Math.max(4, Math.min(x, window.innerWidth - width - 8)),
|
||||
top: Math.max(4, Math.min(y, window.innerHeight - estHeight - 8)),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。
|
||||
import type { RecoveryEntry } from '../services/platform'
|
||||
import { pathCompareKey } from './text'
|
||||
|
||||
export interface RecoveryTabLike {
|
||||
id: string
|
||||
@@ -8,7 +9,7 @@ export interface RecoveryTabLike {
|
||||
|
||||
/** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */
|
||||
export function recoveryKey(tab: RecoveryTabLike): string {
|
||||
return tab.path ? `path:${tab.path}` : `untitled:${tab.id}`
|
||||
return tab.path ? `path:${pathCompareKey(tab.path)}` : `untitled:${tab.id}`
|
||||
}
|
||||
|
||||
export interface RecoveryPlan {
|
||||
|
||||
@@ -20,14 +20,27 @@ export interface OutlineItem {
|
||||
line: number
|
||||
}
|
||||
|
||||
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 */
|
||||
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。
|
||||
* 围栏按字符+长度跟踪:四反引号围栏内的三反引号行不算闭合(docs/29)。 */
|
||||
export function extractOutline(markdown: string): OutlineItem[] {
|
||||
const items: OutlineItem[] = []
|
||||
const lines = markdown.split('\n')
|
||||
let inFence = false
|
||||
let fenceChar = ''
|
||||
let fenceLen = 0
|
||||
lines.forEach((raw, i) => {
|
||||
if (/^\s*(```|~~~)/.test(raw)) inFence = !inFence
|
||||
if (inFence) return
|
||||
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)
|
||||
if (fence) {
|
||||
const run = fence[1]
|
||||
if (!fenceChar) {
|
||||
fenceChar = run[0]
|
||||
fenceLen = run.length
|
||||
} else if (run[0] === fenceChar && run.length >= fenceLen) {
|
||||
fenceChar = ''
|
||||
fenceLen = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
if (fenceChar) return
|
||||
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
|
||||
if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i })
|
||||
})
|
||||
@@ -48,21 +61,28 @@ export function normalizeSlashes(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/** 归一化后比较两条路径是否相同。 */
|
||||
/** 路径比较键:统一斜杠;Windows 风格路径(盘符/UNC)大小写不敏感,一并折叠小写。 */
|
||||
export function pathCompareKey(path: string): string {
|
||||
const slashed = normalizeSlashes(path).replace(/\/+$/, '')
|
||||
return /^[a-zA-Z]:/.test(slashed) || slashed.startsWith('//') ? slashed.toLowerCase() : slashed
|
||||
}
|
||||
|
||||
/** 归一化后比较两条路径是否相同(Windows 路径忽略大小写)。 */
|
||||
export function pathsEqual(a?: string | null, b?: string | null): boolean {
|
||||
return Boolean(a && b && normalizeSlashes(a) === normalizeSlashes(b))
|
||||
return Boolean(a && b && pathCompareKey(a) === pathCompareKey(b))
|
||||
}
|
||||
|
||||
/** child 是否位于 parent 目录之内(不含 parent 本身)。 */
|
||||
export function isPathInside(child: string, parent: string): boolean {
|
||||
const c = normalizeSlashes(child)
|
||||
const p = normalizeSlashes(parent).replace(/\/+$/, '')
|
||||
return c.startsWith(`${p}/`)
|
||||
return pathCompareKey(child).startsWith(`${pathCompareKey(parent)}/`)
|
||||
}
|
||||
|
||||
/** 取父目录(保持原分隔符风格)。 */
|
||||
/** 取父目录(保持原分隔符风格);盘符根下的文件返回盘符根(C:/ 而非相对的 C:)。 */
|
||||
export function parentDirOf(path: string): string {
|
||||
return path.replace(/[\\/][^\\/]*$/, '')
|
||||
const parent = path.replace(/[\\/][^\\/]*$/, '')
|
||||
if (/^[a-zA-Z]:$/.test(parent)) return `${parent}/`
|
||||
if (parent === '' && path.startsWith('/')) return '/'
|
||||
return parent
|
||||
}
|
||||
|
||||
/** 拼接路径(分隔符跟随 dir 的风格)。 */
|
||||
|
||||
@@ -103,3 +103,31 @@ describe('分片载入与整篇解析等价', () => {
|
||||
expect(chunked).toBe(whole)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
describe('列表内嵌围栏不被切开(docs/29)', () => {
|
||||
it('分片序列化与整篇逐字相等', async () => {
|
||||
const parts: string[] = []
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
parts.push(`## 小节 ${s}`)
|
||||
parts.push('')
|
||||
parts.push('正文填充。'.repeat(420))
|
||||
parts.push('')
|
||||
parts.push('- 列表项,内含缩进围栏代码:')
|
||||
parts.push('')
|
||||
parts.push(' ```js')
|
||||
parts.push(` const v${s} = ${s}`)
|
||||
parts.push(' ```')
|
||||
parts.push('')
|
||||
parts.push('- 下一项')
|
||||
parts.push('')
|
||||
}
|
||||
const md = parts.join('\n')
|
||||
expect(md.length).toBeGreaterThan(60_000)
|
||||
const whole = getMarkdown(await load(md))
|
||||
const handle = prepareChunkLoad(md)
|
||||
expect(handle).not.toBeNull()
|
||||
const editor = await load(handle!.firstChunk)
|
||||
await handle!.appendRest(editor, () => undefined, () => false)
|
||||
expect(getMarkdown(editor)).toBe(whole)
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
@@ -22,18 +22,44 @@ describe('Dropdown', () => {
|
||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||
expect(button.textContent).toContain('GitHub')
|
||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
const menu = container.querySelector('.dropdown-menu')
|
||||
const menu = document.querySelector('.dropdown-menu')
|
||||
expect(menu).not.toBeNull()
|
||||
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
||||
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
||||
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(onChange).toHaveBeenCalledWith('notion')
|
||||
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||
expect(document.querySelector('.dropdown-menu')).toBeNull()
|
||||
// 再次展开,点别处关闭
|
||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(container.querySelector('.dropdown-menu')).not.toBeNull()
|
||||
expect(document.querySelector('.dropdown-menu')).not.toBeNull()
|
||||
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
expect(document.querySelector('.dropdown-menu')).toBeNull()
|
||||
await act(async () => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('展开层经 Portal 挂到 body;触发钮靠近窗口底部时向上翻且不溢出', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<Dropdown value="github" options={OPTIONS} onChange={() => undefined} />)
|
||||
})
|
||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||
// 模拟触发钮贴近窗口底部(jsdom 默认视口高 768):下方只剩 30px
|
||||
button.getBoundingClientRect = () => ({
|
||||
top: 700, bottom: 730, left: 100, right: 300, width: 200, height: 30, x: 100, y: 700, toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
// Portal:菜单不在组件容器内,而是 body 直属
|
||||
expect(container.querySelector('.dropdown-menu')).toBeNull()
|
||||
const menu = document.querySelector('.dropdown-menu') as HTMLElement | null
|
||||
expect(menu).not.toBeNull()
|
||||
expect(menu!.style.position).toBe('fixed')
|
||||
// 3 项估算高 101,下方放不下 → 翻到触发钮上方:top = 700 - 101 - 4
|
||||
expect(menu!.style.top).toBe('595px')
|
||||
expect(menu!.style.left).toBe('100px')
|
||||
expect(menu!.style.width).toBe('200px')
|
||||
await act(async () => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
|
||||
43
tests/editor-pane-unload.test.tsx
Normal file
43
tests/editor-pane-unload.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// 回归(docs/29 高危):EditorPane 卸载最终回写的守卫。
|
||||
// 分片载入未收尾(muted)时 cleanup 跳过回写——否则部分序列化会把干净
|
||||
// 标签静默截断。该交错(卸载提交时追加仍在途)在 jsdom 里无法自动化
|
||||
// 复现:React act 会把待办异步工作抽干,分片追加总是先跑完(已实测
|
||||
// 三种卸载方式均如此),故中途卸载一例靠代码复核保证(muted 仅在追加
|
||||
// 完成/失败的收尾回调里解除,cleanup 只在 !muted 时回写),这里只锁
|
||||
// 对照组:正常文档卸载时最终回写照常发生(守卫不能误伤正常路径)。
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import EditorPane from '../src/components/EditorPane'
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
describe('EditorPane 卸载回写', () => {
|
||||
it('普通文档卸载:最终回写照常', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onFinalMarkdown = vi.fn()
|
||||
let readyApi: { editor: unknown } | null = null
|
||||
const onReady = vi.fn((api: { editor: unknown }) => { if (api.editor) readyApi = api })
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditorPane
|
||||
tabId="t1"
|
||||
initialMarkdown={'# 小文档\n\n你好,世界。\n'}
|
||||
onMarkdown={() => undefined}
|
||||
onFinalMarkdown={onFinalMarkdown}
|
||||
onCursor={() => undefined}
|
||||
onReady={onReady}
|
||||
focusMode={false}
|
||||
typewriter={false}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
for (let i = 0; i < 200 && !readyApi; i++) await sleep(10)
|
||||
expect(readyApi).not.toBeNull()
|
||||
await act(async () => { root.unmount() })
|
||||
expect(onFinalMarkdown).toHaveBeenCalledTimes(1)
|
||||
expect(onFinalMarkdown.mock.calls[0][1]).toContain('你好,世界。')
|
||||
}, 30000)
|
||||
})
|
||||
27
tests/encoding-modal.test.tsx
Normal file
27
tests/encoding-modal.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// docs/29 D1:非 UTF-8 文档保存转码提示弹窗的渲染与回调契约。
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { EncodingConvertModal } from '../src/components/Modals'
|
||||
|
||||
describe('EncodingConvertModal', () => {
|
||||
it('显示原编码与文件名,确认/取消回调正确', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
await act(async () => {
|
||||
root.render(<EncodingConvertModal encoding="GBK" fileName="老文档.md" onConfirm={onConfirm} onCancel={onCancel} />)
|
||||
})
|
||||
expect(container.textContent).toContain('GBK')
|
||||
expect(container.textContent).toContain('老文档.md')
|
||||
expect(container.textContent).toContain('转为 UTF-8')
|
||||
const buttons = [...container.querySelectorAll('button')]
|
||||
buttons.find((b) => b.textContent?.includes('转为 UTF-8'))?.click()
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
buttons.find((b) => b.textContent?.includes('取消'))?.click()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { root.unmount() })
|
||||
})
|
||||
})
|
||||
123
tests/git-sync.test.tsx
Normal file
123
tests/git-sync.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// Git 同步:设置解析、状态文案、定时租约互斥、侧栏同步条渲染。
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import {
|
||||
parseGitSyncSettings, gitStatusLabel, gitSyncCommitMessage,
|
||||
acquireGitSyncLease, releaseGitSyncLease,
|
||||
} from '../src/services/gitSync'
|
||||
import type { GitStatusInfo } from '../src/services/platform'
|
||||
|
||||
vi.mock('../src/services/platform', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../src/services/platform')>()
|
||||
return { ...orig, listDir: async () => [] }
|
||||
})
|
||||
|
||||
import Sidebar from '../src/components/Sidebar'
|
||||
|
||||
const base: GitStatusInfo = {
|
||||
isRepo: true, branch: 'main', remoteUrl: 'https://example.com/n.git',
|
||||
dirtyCount: 0, ahead: 0, behind: 0, hasUpstream: true,
|
||||
}
|
||||
|
||||
describe('parseGitSyncSettings', () => {
|
||||
it('空/坏数据回默认(手动)', () => {
|
||||
expect(parseGitSyncSettings(null).intervalMin).toBe(0)
|
||||
expect(parseGitSyncSettings('{bad').intervalMin).toBe(0)
|
||||
expect(parseGitSyncSettings('{"intervalMin":-3}').intervalMin).toBe(0)
|
||||
})
|
||||
it('正常解析间隔', () => {
|
||||
expect(parseGitSyncSettings('{"intervalMin":15}').intervalMin).toBe(15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gitStatusLabel', () => {
|
||||
it('未启用 / 同步中 / 已同步', () => {
|
||||
expect(gitStatusLabel({ ...base, isRepo: false }, false)).toBe('未启用 Git 同步')
|
||||
expect(gitStatusLabel(base, true)).toBe('正在同步…')
|
||||
expect(gitStatusLabel(base, false)).toBe('main · 已同步')
|
||||
})
|
||||
it('待提交/待推送/待拉取计数', () => {
|
||||
expect(gitStatusLabel({ ...base, dirtyCount: 2, ahead: 1, behind: 3 }, false))
|
||||
.toBe('main · 待提交 2 处 · 待推送 1 · 待拉取 3')
|
||||
})
|
||||
it('无远程时提示未设置远程', () => {
|
||||
expect(gitStatusLabel({ ...base, remoteUrl: null, hasUpstream: false }, false))
|
||||
.toBe('main · 未设置远程 · 已同步')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gitSyncCommitMessage', () => {
|
||||
it('带本地时间戳与手动/自动区分', () => {
|
||||
const d = new Date(2026, 10, 14, 9, 5)
|
||||
expect(gitSyncCommitMessage(true, d)).toBe('自动同步 2026-11-14 09:05(new-markdown)')
|
||||
expect(gitSyncCommitMessage(false, d)).toContain('手动同步 2026-11-14 09:05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时同步租约', () => {
|
||||
it('别的窗口新鲜占用时取不到,过期/释放后可取', () => {
|
||||
localStorage.clear()
|
||||
const t0 = 1_000_000
|
||||
expect(acquireGitSyncLease('C:/ws', 'win-a', t0)).toBe(true)
|
||||
expect(acquireGitSyncLease('C:/ws', 'win-b', t0 + 5_000)).toBe(false)
|
||||
expect(acquireGitSyncLease('C:/ws', 'win-a', t0 + 6_000)).toBe(true) // 自己续租
|
||||
expect(acquireGitSyncLease('C:/ws', 'win-b', t0 + 40_000)).toBe(true) // 已过期
|
||||
releaseGitSyncLease('C:/ws', 'win-a') // 不是持有者,无效
|
||||
expect(acquireGitSyncLease('C:/ws', 'win-c', t0 + 41_000)).toBe(false)
|
||||
releaseGitSyncLease('C:/ws', 'win-b')
|
||||
expect(acquireGitSyncLease('C:/ws', 'win-c', t0 + 42_000)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('侧栏 Git 同步条', () => {
|
||||
async function renderBar(gitSync: Record<string, unknown>) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Sidebar
|
||||
mode="files"
|
||||
onModeChange={() => undefined}
|
||||
workspaceRoot="C:/ws"
|
||||
activePath={null}
|
||||
onOpenFile={() => undefined}
|
||||
onOpenFolder={() => undefined}
|
||||
outline={[]}
|
||||
onJumpHeading={() => undefined}
|
||||
tauriMode
|
||||
gitSync={gitSync as never}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)) })
|
||||
return container
|
||||
}
|
||||
|
||||
it('未启用时显示状态与设置按钮,无立即同步', async () => {
|
||||
const c = await renderBar({
|
||||
isRepo: false, label: '未启用 Git 同步', syncing: false,
|
||||
lastMessage: null, lastIsError: false,
|
||||
onSyncNow: () => undefined, onOpenSettings: () => undefined,
|
||||
})
|
||||
expect(c.textContent).toContain('Git 同步')
|
||||
expect(c.textContent).toContain('未启用 Git 同步')
|
||||
expect(c.querySelector('[aria-label="Git 同步设置"]')).not.toBeNull()
|
||||
expect(c.querySelector('[aria-label="立即同步"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('仓库状态下显示立即同步且可点,结果行可见', async () => {
|
||||
const onSyncNow = vi.fn()
|
||||
const c = await renderBar({
|
||||
isRepo: true, label: 'main · 待提交 2 处', syncing: false,
|
||||
lastMessage: '已提交本地更改', lastIsError: false,
|
||||
onSyncNow, onOpenSettings: () => undefined,
|
||||
})
|
||||
expect(c.textContent).toContain('待提交 2 处')
|
||||
expect(c.textContent).toContain('已提交本地更改')
|
||||
const btn = c.querySelector('[aria-label="立即同步"]') as HTMLButtonElement
|
||||
await act(async () => { btn.click() })
|
||||
expect(onSyncNow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,9 @@ import type { RecoveryEntry } from '../src/services/platform'
|
||||
|
||||
describe('recoveryKey', () => {
|
||||
it('文件文档按路径键,未命名按标签 id', () => {
|
||||
expect(recoveryKey({ id: 't1', path: 'D:\\a.md' })).toBe('path:D:\\a.md')
|
||||
// docs/29:路径先归一(斜杠统一 + Windows 大小写折叠),同文件不同写法同键
|
||||
expect(recoveryKey({ id: 't1', path: 'D:\\a.md' })).toBe('path:d:/a.md')
|
||||
expect(recoveryKey({ id: 't1', path: 'd:/A.md' })).toBe('path:d:/a.md')
|
||||
expect(recoveryKey({ id: 't2', path: null })).toBe('untitled:t2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { countWords, countChars, extractOutline, baseName, isMarkdownPath } from '../src/utils/text'
|
||||
import { countWords, countChars, extractOutline, baseName, isMarkdownPath, pathsEqual, isPathInside, parentDirOf } from '../src/utils/text'
|
||||
|
||||
describe('字数统计', () => {
|
||||
it('中文按字、英文按词、标点不计', () => {
|
||||
@@ -56,3 +56,24 @@ describe('路径助手(拖放/改名校验)', () => {
|
||||
expect(isPathInside('C:\\ws\\sub\\deep', 'C:/ws/sub')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('路径比较与大纲围栏(docs/29)', () => {
|
||||
it('Windows 路径大小写/斜杠归一后相等,POSIX 路径仍分大小写', () => {
|
||||
expect(pathsEqual('C:\\WS\\A.md', 'c:/ws/a.md')).toBe(true)
|
||||
expect(pathsEqual('/home/A.md', '/home/a.md')).toBe(false)
|
||||
expect(isPathInside('c:/ws/sub/a.md', 'C:\\WS')).toBe(true)
|
||||
expect(isPathInside('C:/ws2/a.md', 'C:/ws')).toBe(false)
|
||||
})
|
||||
|
||||
it('parentDirOf 盘符根文件返回盘符根', () => {
|
||||
expect(parentDirOf('C:/a.md')).toBe('C:/')
|
||||
expect(parentDirOf('C:\\dir\\a.md')).toBe('C:\\dir')
|
||||
expect(parentDirOf('/a.md')).toBe('/')
|
||||
})
|
||||
|
||||
it('四反引号围栏内的三反引号与 # 行不干扰大纲', () => {
|
||||
const md = ['# 标题一', '', '````md', '```', '# 假标题', '```', '````', '', '# 标题二'].join('\n')
|
||||
const outline = extractOutline(md)
|
||||
expect(outline.map((o) => o.text)).toEqual(['标题一', '标题二'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user