From 61896648b16b60b6fa9673516b8a10e426a005ad Mon Sep 17 00:00:00 2001 From: weicheng <1648186705@qq.com> Date: Sun, 8 Nov 2026 18:39:48 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=95=B4=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=20review=20=E7=9A=84=E7=AC=AC=E4=B8=80=E6=89=B9?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同步 RestoreLocal 不再走「内容相同」捷径,先把文件写回本地,避免下一轮把远端唯一副本当成用户删除 - 「本地全部消失」安全阀把 RestoreLocal 计入,目录被清空时不再传播部分远端删除 - 设置文件瞬时读失败时禁止本次运行写回,避免默认值覆盖原配置 - OneDrive 按需文件占位符不再被当成符号链接,保存与历史恢复可用 - Markdown 模式给形似条目头的正文行加保护空格,避免被拆成幽灵条目 Co-Authored-By: Claude Code --- src-tauri/src/commands.rs | 5 +++ src-tauri/src/files.rs | 25 ++++-------- src-tauri/src/main.rs | 10 ++++- src-tauri/src/notes.rs | 83 ++++++++++++++++++++++++++++++++++++++- src-tauri/src/settings.rs | 41 ++++++++++++++++--- src-tauri/src/sync/mod.rs | 77 +++++++++++++++++++++++++++++++++--- 6 files changed, 210 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e89c404..20bec0a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -84,6 +84,11 @@ pub fn persist_sync(app: &AppHandle) -> Result<(), String> { } fn write_snapshot(state: &AppState, path: &Path, seq: u64, snapshot: &Settings) -> bool { + if state.settings_read_failed { + // 启动时读不出来:内存里是默认值,写回会把原配置覆盖掉。 + log::error!("设置文件启动时读取失败,本次运行不写回设置"); + return false; + } write_snapshot_locked( path, seq, diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 13ee9c0..4384c53 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -23,24 +23,15 @@ pub(crate) fn io_guard(state: &AppState) -> MutexGuard<'_, ()> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } -#[cfg(windows)] -const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; - -/// 路径本身是不是符号链接/重解析点;只查路径,不跟随目标。 +/// 路径本身是不是符号链接/目录联接;只查路径,不跟随目标。 +/// +/// 不能只看 `FILE_ATTRIBUTE_REPARSE_POINT`:OneDrive「按需文件」的云端占位符 +/// 也带这个属性(实测 0x401620),但它们就是普通笔记文件。`FileType::is_symlink` +/// 会继续检查重解析标签,只把符号链接与 junction 算作链接。 pub(crate) fn is_reparse_point(path: &Path) -> bool { - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - fs::symlink_metadata(path) - .map(|meta| meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) - .unwrap_or(false) - } - #[cfg(not(windows))] - { - fs::symlink_metadata(path) - .map(|meta| meta.file_type().is_symlink()) - .unwrap_or(false) - } + fs::symlink_metadata(path) + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false) } /// 拒绝写入重解析点,避免把笔记/同步内容写到链接目标(可能是目录外文件)。 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f7ab6f6..04617eb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -118,6 +118,8 @@ pub struct AppState { pub settings_last_saved_seq: AtomicU64, /// 串行化设置文件写入,避免两个快照同时写同一个临时文件。 pub settings_write_lock: Mutex<()>, + /// 启动时设置文件读不出来:本次运行不写回,避免默认值覆盖原文件。 + pub settings_read_failed: bool, /// 最近一次自动 Git 提交的结果,供设置页显示失败原因。 pub(crate) git: Mutex, startup_notice: Mutex>, @@ -169,6 +171,7 @@ fn main() { let handle = app.handle().clone(); let loaded = settings::load(&handle); let settings_path = settings::settings_path(&handle); + let settings_read_failed = loaded.read_failed; let mut settings = loaded.settings; let mut notices = Vec::new(); let mut open_settings = false; @@ -195,8 +198,10 @@ fn main() { Some(candidate) => { log::warn!("已改用备用快捷键 {candidate}"); settings.hotkey = candidate.to_string(); - if let Err(save_err) = settings::save(&settings_path, &settings) { - log::error!("保存备用快捷键失败:{save_err}"); + if !settings_read_failed { + if let Err(save_err) = settings::save(&settings_path, &settings) { + log::error!("保存备用快捷键失败:{save_err}"); + } } notices.push(format!( "快捷键 {failed} 被占用,已临时改用 {candidate},可在设置中修改" @@ -229,6 +234,7 @@ fn main() { settings_write_seq: AtomicU64::new(0), settings_last_saved_seq: AtomicU64::new(0), settings_write_lock: Mutex::new(()), + settings_read_failed, git: Mutex::new(git_backup::GitCommitState::default()), startup_notice: Mutex::new(startup_notice), }); diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 812740f..1783df4 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -113,7 +113,31 @@ pub fn format_entry(time: &str, content: &str, source: Option<&str>) -> String { /// Markdown 模式:时间作为三级标题,原文不做缩进,来源写成 HTML 注释。 pub fn format_entry_markdown(time: &str, content: &str, source: Option<&str>) -> String { - let mut out = format!("### {time}\n{}", content.trim()); + let mut out = format!("### {time}"); + let mut previous_blank = false; + let mut open_fence: Option = None; + for line in content.trim().lines() { + out.push('\n'); + if let Some(open) = open_fence { + // 围栏里的内容一个字都不能改。 + out.push_str(line); + if closes_fence(line, open) { + open_fence = None; + } + } else { + // 正文里形似条目头的行加一个保护空格:Markdown 渲染不变(标题与 + // 列表允许 1–3 个空格缩进),但 split_spans 不会把它当成新条目。 + if needs_protection(line, previous_blank) { + out.push(' '); + } + out.push_str(line); + open_fence = fence_marker(line); + } + previous_blank = line.trim().is_empty(); + } + if content.trim().is_empty() { + out.push('\n'); + } if let Some(source) = sanitize_source(source) { out.push_str("\n\n` 只是正文 + // 围栏内的内容原样保留,`` 只是正文。 + // Markdown 正文里写入时加的保护空格在这里去掉。 + let line = if !compact && !fenced[index] { + strip_protection(line, previous_blank) + } else { + line.as_str() + }; content.push(content_line(line, compact)); + previous_blank = line.trim().is_empty(); } let content = content.join("\n").trim_matches('\n').trim_end().to_string(); @@ -347,6 +380,22 @@ fn list_time(line: &str) -> Option<(String, String)> { is_hhmm(time).then(|| (time.to_string(), first.to_string())) } +/// 这行在 Markdown 正文里会不会被当成条目头。写入时加一个前导空格保护: +/// `### HH:MM` 任何时候都算条目头;`- HH:MM` 只在空行之后算。 +fn needs_protection(line: &str, previous_blank: bool) -> bool { + (!line.starts_with(char::is_whitespace) && markdown_time(line).is_some()) + || (previous_blank && list_time(line).is_some()) +} + +/// 去掉写入时加的保护空格;只有去掉后确实像条目头才去,避免改动用户 +/// 自己缩进的内容。 +fn strip_protection(line: &str, previous_blank: bool) -> &str { + match line.strip_prefix(' ') { + Some(rest) if needs_protection(rest, previous_blank) => rest, + _ => line, + } +} + fn source_line(line: &str) -> Option { let value = line.trim(); let raw = value @@ -1038,6 +1087,36 @@ mod tests { assert_eq!(entries[1].1, "新条目"); } + #[test] + fn markdown_time_like_body_lines_round_trip_as_one_entry() { + let content = "会议安排\n\n- 10:00 开场\n- 11:00 讨论\n\n### 12:00 午餐"; + let formatted = format_entry_markdown("09:00", content, None); + let entries = parse_entries(&format!("# 2026-10-21\n\n{formatted}\n")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, "09:00"); + assert_eq!(entries[0].1, content); + } + + #[test] + fn markdown_body_heading_like_first_line_round_trips() { + let content = "### 12:00 午餐\n正文"; + let formatted = format_entry_markdown("09:00", content, None); + let entries = parse_entries(&format!("# 2026-10-21\n\n{formatted}\n")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].1, content); + } + + #[test] + fn markdown_fence_content_is_not_protected() { + let content = "```\n\n- 10:00 代码\n### 11:00 代码\n```"; + let formatted = format_entry_markdown("09:00", content, None); + assert!(formatted.contains("\n- 10:00 代码"), "{formatted}"); + assert!(formatted.contains("\n### 11:00 代码"), "{formatted}"); + let entries = parse_entries(&format!("# 2026-10-21\n\n{formatted}\n")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].1, content); + } + #[test] fn source_like_line_in_the_middle_stays_content() { let text = "- 10:00 a\n _来源:会议记录_\n 正文\n"; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e205c19..81952ea 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,8 +1,9 @@ use chrono::Local; use serde::{Deserialize, Serialize}; use std::{ - fs, + fs, io, path::{Path, PathBuf}, + time::Duration, }; use tauri::{AppHandle, Manager}; @@ -176,6 +177,9 @@ impl Default for Settings { pub struct LoadedSettings { pub settings: Settings, pub warning: Option, + /// 设置文件存在但读不出来(被锁、权限等)。本次运行禁止写回,避免用 + /// 默认值覆盖仍然有效的原文件。 + pub read_failed: bool, } pub fn settings_path(app: &AppHandle) -> PathBuf { @@ -201,11 +205,30 @@ pub fn load(app: &AppHandle) -> LoadedSettings { loaded } +/// 读设置文件。杀软或同步盘可能短暂锁住文件,重试几次再放弃。 +fn read_settings_file(path: &Path) -> io::Result { + let mut last = None; + for attempt in 0..3 { + match fs::read_to_string(path) { + Ok(raw) => return Ok(raw), + Err(err) if err.kind() == io::ErrorKind::NotFound => return Err(err), + Err(err) => { + last = Some(err); + if attempt < 2 { + std::thread::sleep(Duration::from_millis(80)); + } + } + } + } + Err(last.unwrap_or_else(|| io::Error::other("读取设置文件失败"))) +} + /// 读取指定路径的设置文件。损坏时先备份原文件,再返回默认值。 pub(crate) fn load_from_path(path: &Path) -> LoadedSettings { let mut warning = None; + let mut read_failed = false; - let mut settings = match fs::read_to_string(path) { + let mut settings = match read_settings_file(path) { Ok(raw) => match parse_settings(&raw) { // 手工编辑出的非法值只纠正字段,不把整份设置当成损坏 Ok(mut parsed) => { @@ -230,9 +253,13 @@ pub(crate) fn load_from_path(path: &Path) -> LoadedSettings { recovered_default() } }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(), + Err(err) if err.kind() == io::ErrorKind::NotFound => Settings::default(), Err(err) => { - log::warn!("读取设置文件失败({}):{err}", path.display()); + log::error!("读取设置文件失败({}):{err}", path.display()); + // 读不出来 ≠ 没有设置。用默认值继续运行,但本次运行不能写回, + // 否则一次瞬时锁文件就会把笔记目录、快捷键和云同步配置全部抹掉。 + read_failed = true; + warning = Some("设置文件暂时无法读取,本次运行的修改不会保存;请重启应用".to_string()); recovered_default() } }; @@ -240,7 +267,11 @@ pub(crate) fn load_from_path(path: &Path) -> LoadedSettings { // 手改出的相对路径会跟着进程 CWD 漂移(自启动 vs 手动启动),读到时就固定成绝对路径 settings.notes_dir = absolute_notes_dir(&settings.notes_dir); - LoadedSettings { settings, warning } + LoadedSettings { + settings, + warning, + read_failed, + } } /// 宽松解析:单个字段类型写错(例如 `intervalMinutes` 写成字符串)时只丢弃该 diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 4ba02a0..7f369e8 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -551,10 +551,16 @@ impl SyncEngine { && deletions * 100 > tracked.saturating_mul(DELETE_RATIO_PERCENT); // 「本地消失」只算本地真的没了的已跟踪文件:TrashLocal 是远端删除传播到 // 本地,本地内容还在,不属于「目录被清空」。Forget 也算,避免远端刚好已删 - // 掉一两篇时差几个数漏判。 + // 掉一两篇时差几个数漏判。RestoreLocal 同样是「本地没了、远端还在」, + // 漏掉它会让目录被清空时仍删掉那些远端没变的文件。 let local_gone = plans .iter() - .filter(|(_, action)| matches!(action, Action::DeleteRemote | Action::Forget)) + .filter(|(_, action)| { + matches!( + action, + Action::DeleteRemote | Action::Forget | Action::RestoreLocal + ) + }) .count(); let remote_deletes = plans .iter() @@ -742,9 +748,12 @@ impl SyncEngine { } // 版本令牌可能来自 GET 与 PROPFIND 两个来源。内容与我们上次 // 同步的完全一致时,只把列表版本补进记录,避免反复下载。 - if record - .as_ref() - .is_some_and(|record| sha256_base64(&bytes) == record.remote_sha256) + // 仅限 Download:RestoreLocal 表示本地文件已经不存在,必须 + // 先把内容写回去,否则下一轮会把它当成用户删除而删掉远端。 + if matches!(action, Action::Download) + && record + .as_ref() + .is_some_and(|record| sha256_base64(&bytes) == record.remote_sha256) { let version = remote.map(|meta| meta.version.clone()).unwrap_or(version); self.commit(name, &bytes, version, summary).await; @@ -1676,6 +1685,28 @@ mod tests { assert_eq!(summary.skipped, 1, "{summary:?}"); } + #[test] + fn restore_local_writes_the_file_instead_of_recording_a_missing_one() { + let backend = FakeBackend::strong(); + let harness = Harness::with_backend("restore-local-dedupe", backend.clone()); + let text = "# 2026-10-23\n\n- 10:00 a\n"; + harness.write("2026-10-23.md", text); + assert_eq!(harness.run().uploaded, 1); + + // 本地文件意外消失,同时远端版本令牌漂移但内容没变。 + fs::remove_file(harness.notes.join("2026-10-23.md")).unwrap(); + backend.diverge_list_versions(); + + let summary = harness.run(); + assert_eq!(summary.downloaded, 1, "{summary:?}"); + assert_eq!(harness.read("2026-10-23.md"), text); + + // 下一轮不能把远端唯一副本当成用户删除。 + let summary = harness.run(); + assert_eq!(summary.deleted, 0, "{summary:?}"); + assert!(backend.content("2026-10-23.md").is_some()); + } + #[test] fn concurrent_edits_converge_without_duplicates() { let backend = FakeBackend::strong(); @@ -1832,6 +1863,42 @@ mod tests { assert_eq!(harness.backend.names().len(), 3); } + #[test] + fn safety_valve_counts_restore_local_as_a_missing_local_file() { + let backend = FakeBackend::strong(); + let harness = Harness::with_backend("valve-restore-local", backend.clone()); + for day in 1..=4 { + harness.write( + &format!("2026-10-{day:02}.md"), + &format!("# 2026-10-{day:02}\n"), + ); + } + harness.run(); + + // 目录被清空,同时另一台设备改了其中两篇:这两篇会走 RestoreLocal。 + for day in 1..=4 { + fs::remove_file(harness.notes.join(format!("2026-10-{day:02}.md"))).unwrap(); + } + backend.put_remote( + "2026-10-01.md", + b"# 2026-10-01\n\n- 11:00 changed\n".to_vec(), + ); + backend.put_remote( + "2026-10-02.md", + b"# 2026-10-02\n\n- 11:00 changed\n".to_vec(), + ); + + let summary = harness.run(); + assert!( + summary.errors.iter().any(|error| error.contains("安全阀")), + "{summary:?}" + ); + assert_eq!(summary.deleted, 0, "{summary:?}"); + // 未变的远端文件不能被删。 + assert!(backend.content("2026-10-03.md").is_some()); + assert!(backend.content("2026-10-04.md").is_some()); + } + #[test] fn local_deletion_backs_up_remote_copy_before_delete() { let harness = Harness::new("delete-backup"); From 70bec0b7ad43e5d469088aaad2d5bd3b794cfe42 Mon Sep 17 00:00:00 2001 From: weicheng <1648186705@qq.com> Date: Sun, 8 Nov 2026 18:42:38 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20review=20?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=89=B9=E5=AE=89=E5=85=A8=E4=B8=8E=E7=A1=AE?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E6=95=85=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 托盘「打开今日笔记」拒绝符号链接,避免 ShellExecuteW 被引到目录外 - Git 自动提交只认真实的 .git 目录,gitfile 重定向不再操作目录外仓库 - git check-attr 改走标准输入,笔记多时不再撞 Windows 32K 命令行上限 - 合并结果超过 2 MiB 时改为保存冲突副本,不再产生永远不同步的文件 - 同步警告(超大/不可读文件等)显示在状态行与悬停提示里 - explorer 定位网络共享笔记时正确还原 \?\UNC\ 前缀 Co-Authored-By: Claude Code --- src-tauri/src/files.rs | 27 ++++++++++-- src-tauri/src/git_backup.rs | 82 ++++++++++++++++++++++++++++++------- src-tauri/src/sync/mod.rs | 10 +++++ src/sync.ts | 22 +++++++++- 4 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 4384c53..3357b67 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -83,12 +83,20 @@ pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { result } +/// explorer 不认 `\\?\` verbatim 前缀,遇到会直接报「找不到文件」;先剥掉再传。 +/// UNC 路径的 verbatim 形式是 `\\?\UNC\server\share`,剥完要还原成 `\\server\share`。 +fn explorer_path(display: &str) -> String { + if let Some(rest) = display.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else { + display.strip_prefix(r"\\?\").unwrap_or(display).to_string() + } +} + fn open_with_explorer(path: &Path) -> Result<(), String> { - // explorer 不认 `\\?\` verbatim 前缀,遇到会直接报「找不到文件」;先剥掉再传 let display = path.to_string_lossy(); - let target = display.strip_prefix(r"\\?\").unwrap_or(&display); std::process::Command::new("explorer") - .arg(target) + .arg(explorer_path(&display)) .spawn() .map(|_| ()) .map_err(|err| err.to_string()) @@ -170,6 +178,9 @@ fn ensure_note_file(dir: &Path, date: &str) -> Result { Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {} Err(err) => return Err(format!("创建今日笔记失败:{err}")), } + // 已存在时也要确认不是符号链接:托盘「打开今日笔记」会把它交给系统, + // 共享目录里预先放好的链接可能把 ShellExecuteW 引到目录外。 + reject_reparse(&path).map_err(|err| format!("今日笔记不可用:{err}"))?; Ok(path) } @@ -504,6 +515,16 @@ mod tests { ); } + #[test] + fn explorer_path_strips_verbatim_prefixes() { + assert_eq!(explorer_path(r"\\?\C:\notes\a.md"), r"C:\notes\a.md"); + assert_eq!( + explorer_path(r"\\?\UNC\nas\notes\a.md"), + r"\\nas\notes\a.md" + ); + assert_eq!(explorer_path(r"\\nas\notes\a.md"), r"\\nas\notes\a.md"); + } + #[test] fn resolve_within_rejects_missing_file() { let dir = temp_dir("resolve-missing"); diff --git a/src-tauri/src/git_backup.rs b/src-tauri/src/git_backup.rs index 8d76b8d..e308fef 100644 --- a/src-tauri/src/git_backup.rs +++ b/src-tauri/src/git_backup.rs @@ -6,6 +6,7 @@ //! 提交失败只记日志,绝不影响保存本身:笔记落盘永远优先于版本历史。 use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -42,7 +43,7 @@ pub(crate) struct GitCommitState { pub(crate) struct GitStatus { /// 系统里能找到 `git` 命令。 pub available: bool, - /// 笔记目录本身是一个仓库(存在 `.git`)。 + /// 笔记目录本身是一个仓库(存在真实的 `.git` 目录)。 pub repo: bool, pub last_success: Option, pub last_error: Option, @@ -57,8 +58,15 @@ pub(crate) fn status(dir: &Path, last: &GitCommitState) -> GitStatus { } } +/// 笔记目录本身是一个普通仓库:`.git` 必须是真实目录。 +/// +/// `.git` 也可以是 gitfile(内容 `gitdir: …`),但那会把提交引到另一个仓库。 +/// 共享/网络笔记目录里被放入这样一个文件时,自动提交就变成了对目录外仓库的写入。 pub(crate) fn is_repo(dir: &Path) -> bool { - dir.join(".git").exists() + let git = dir.join(".git"); + fs::symlink_metadata(&git) + .map(|meta| meta.is_dir() && !crate::files::is_reparse_point(&git)) + .unwrap_or(false) } /// 系统里有没有 git。找不到时设置项直接禁用,而不是等保存后才报错。 @@ -76,6 +84,11 @@ pub(crate) fn init(dir: &Path) -> Result<(), String> { if is_repo(dir) { return Ok(()); } + // `.git` 是文件或链接时(worktree 或恶意的 gitdir 重定向)不要初始化: + // `git init` 可能顺着它去改另一个仓库。 + if fs::symlink_metadata(dir.join(".git")).is_ok() { + return Err("笔记目录里的 .git 不是普通目录,已拒绝初始化".to_string()); + } let out = run(Some(dir), &["init", "-q"])?; if !out.status.success() { return Err(format!("git init 失败:{}", stderr(&out))); @@ -319,9 +332,18 @@ fn has_external_filter(dir: &Path, names: &[String]) -> Result { if names.is_empty() { return Ok(false); } - let mut args: Vec<&str> = vec!["check-attr", "-z", "filter", "--"]; - args.extend(names.iter().map(String::as_str)); - let out = run(Some(dir), &args)?; + // 路径走标准输入:笔记多了也不会超过 Windows 32K 命令行上限 + // (约 2,200 个日期文件就会撞上)。 + let mut input = Vec::with_capacity(names.iter().map(|name| name.len() + 1).sum()); + for name in names { + input.extend_from_slice(name.as_bytes()); + input.push(0); + } + let out = run_with_stdin( + Some(dir), + &["check-attr", "-z", "--stdin", "filter"], + &input, + )?; if !out.status.success() { return Err(format!("检查 Git filter 失败:{}", stderr(&out))); } @@ -397,11 +419,7 @@ fn run(dir: Option<&Path>, args: &[&str]) -> Result { run_with_env(dir, args, &[]) } -fn run_with_env( - dir: Option<&Path>, - args: &[&str], - envs: &[(&str, String)], -) -> Result { +fn git_command(dir: Option<&Path>, envs: &[(&str, String)]) -> Command { let mut command = Command::new("git"); if dir.is_some() { // 笔记目录可能是共享盘/NAS:仓库里的 hooks、fsmonitor 和签名配置 @@ -416,11 +434,6 @@ fn run_with_env( "commit.gpgsign=false", ]); } - command - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); for (key, value) in envs { command.env(key, value); } @@ -430,10 +443,41 @@ fn run_with_env( #[cfg(windows)] command.creation_flags(CREATE_NO_WINDOW); command +} + +fn run_with_env( + dir: Option<&Path>, + args: &[&str], + envs: &[(&str, String)], +) -> Result { + git_command(dir, envs) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .output() .map_err(|error| format!("无法运行 git:{error}")) } +/// 从标准输入喂路径,避开命令行长度上限。 +fn run_with_stdin(dir: Option<&Path>, args: &[&str], input: &[u8]) -> Result { + let mut child = git_command(dir, &[]) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("无法运行 git:{error}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(input) + .map_err(|error| format!("向 git 写入路径失败:{error}"))?; + } + child + .wait_with_output() + .map_err(|error| format!("等待 git 失败:{error}")) +} + /// git 的报错主要在 stderr;有些版本也会写 stdout。 fn stderr(out: &Output) -> String { let text = String::from_utf8_lossy(&out.stderr); @@ -457,6 +501,14 @@ mod tests { dir } + #[test] + fn gitfile_is_not_treated_as_a_repository() { + let dir = temp_dir("gitfile"); + fs::write(dir.join(".git"), "gitdir: C:/elsewhere/.git\n").unwrap(); + assert!(!is_repo(&dir)); + assert!(init(&dir).is_err()); + } + #[test] fn init_creates_a_repo_only_once() { if !available() { diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 7f369e8..81fbef4 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -848,6 +848,16 @@ impl SyncEngine { } else { result.text.clone() }; + // 合并结果超过协议上限时不能上传:本地会被扫描永久跳过, + // 远端也会被 GitHub 拒绝。退回冲突副本,至少两边内容都留着。 + if merged_text.len() as u64 > MAX_FILE_BYTES { + summary + .warnings + .push(format!("{name}:合并结果超过 2 MiB,已改为保存冲突副本")); + self.conflict_copy(name, local, &remote_text, remote_version, summary) + .await; + return; + } if merged_text != local.text { if let Err(error) = self .write_local( diff --git a/src/sync.ts b/src/sync.ts index aeeafd7..5514c94 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -108,7 +108,11 @@ function paintSyncIndicator(status: SyncStatus): void { if (status.lastSyncAt) { el.textContent = "已同步"; el.classList.add("synced"); - el.title = `上次同步 ${formatTime(status.lastSyncAt)}`; + const warnings = status.summary?.warnings ?? []; + el.title = + warnings.length > 0 + ? `上次同步 ${formatTime(status.lastSyncAt)};${warnings.join(";")}` + : `上次同步 ${formatTime(status.lastSyncAt)}`; } else { el.textContent = "等待同步"; } @@ -143,6 +147,13 @@ export function describeStatus(status: SyncStatus): string[] { `上传 ${summary.uploaded} · 下载 ${summary.downloaded} · 合并 ${summary.merged} · 删除 ${summary.deleted}`, ); } + // 警告(超过 2 MiB、文件读不了等)以前只留在后端日志里,界面照样显示 + // 「已同步」;这里至少把第一条摆到状态行上。 + if (summary.warnings.length > 0) { + const rest = + summary.warnings.length > 1 ? `(共 ${summary.warnings.length} 条)` : ""; + parts.push(`${summary.warnings[0]}${rest}`); + } } return parts; } @@ -159,8 +170,15 @@ export function paintSyncStatus(status: SyncStatus): void { ui.syncError.textContent = ""; } + const hasWarnings = Boolean( + status.summary?.warnings.length && + status.configured && + status.enabled && + !status.running && + !status.lastError, + ); ui.syncStatusText.textContent = describeStatus(status).join(" · "); - ui.syncStatusText.classList.toggle("warn", Boolean(status.degraded)); + ui.syncStatusText.classList.toggle("warn", Boolean(status.degraded) || hasWarnings); ui.syncStatusText.classList.toggle("bad", Boolean(status.lastError)); ui.syncFootStatus.textContent = From b098d409c309e7e90a2896ffe8065485fb6e4801 Mon Sep 17 00:00:00 2001 From: weicheng <1648186705@qq.com> Date: Sun, 8 Nov 2026 18:46:18 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20review=20?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=89=B9=E7=AB=9E=E6=80=81=E4=B8=8E=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同步扫描读文件时与追加/改写共用 io 锁,避免上传半截笔记 - WebDAV 回读不一致时不再无条件重写;有强 ETag 才用 If-Match 补写 - Hash 版本复核后把 GET 的强 ETag 升级为 If-Match,缩小删除/写入竞态 - 条目编辑保存期间的新输入不再被 close() 丢弃,重复提交也会被忽略 - 重开设置面板不再清空未保存的同步表单 - 跨 DPI 多屏恢复使用保存位置所在显示器的缩放 - 清除同步密码时一并删除 0.5.x 旧凭据,避免迁移回退复活 Co-Authored-By: Claude Code --- src-tauri/src/geometry.rs | 25 ++++++++++- src-tauri/src/sync/mod.rs | 21 +++++++-- src-tauri/src/sync/secrets.rs | 13 +++++- src-tauri/src/sync/webdav.rs | 81 ++++++++++++++++++++++++++++------- src/history.ts | 11 +++++ src/settings-panel.ts | 5 +++ 6 files changed, 133 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/geometry.rs b/src-tauri/src/geometry.rs index 07ffc92..72f6083 100644 --- a/src-tauri/src/geometry.rs +++ b/src-tauri/src/geometry.rs @@ -47,6 +47,23 @@ fn overlap_area(a: Rect, b: Rect) -> i64 { } } +/// 保存位置所在显示器当前的缩放。窗口刚创建时在主屏上, +/// `window.scale_factor()` 拿到的是主屏的,不能代表保存时那块屏。 +fn monitor_scale_at(window: &WebviewWindow, x: i32, y: i32) -> Option { + window + .available_monitors() + .ok()? + .into_iter() + .find(|monitor| { + let area = monitor.work_area(); + x >= area.position.x + && x < area.position.x.saturating_add(area.size.width as i32) + && y >= area.position.y + && y < area.position.y.saturating_add(area.size.height as i32) + }) + .map(|monitor| monitor.scale_factor()) +} + fn monitor_rects(window: &WebviewWindow) -> Vec { window .available_monitors() @@ -208,8 +225,12 @@ pub fn apply_initial_geometry(app: &AppHandle) { match saved { Some(geometry) => { let monitors = monitor_rects(&window); - // 物理像素会随 DPI 缩放变化,按保存时的缩放换算回同样的逻辑位置/尺寸 + // 物理像素会随 DPI 缩放变化,按保存时的缩放换算回同样的逻辑位置/尺寸。 + // 窗口启动时在主屏,scale_factor() 是主屏的;保存的几何可能在另一块屏, + // 必须用保存位置那块屏的当前缩放。 let current_scale = window.scale_factor().unwrap_or(1.0); + let target_scale = + monitor_scale_at(&window, geometry.x, geometry.y).unwrap_or(current_scale); let scaled = scale_geometry( Rect { x: geometry.x, @@ -218,7 +239,7 @@ pub fn apply_initial_geometry(app: &AppHandle) { h: geometry.h, }, geometry.scale, - current_scale, + target_scale, ); let (x, y) = clamp_rect(scaled, &monitors); let (w, h) = clamp_size(scaled.w, scaled.h, x, y, &monitors); diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 81fbef4..f994168 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -331,7 +331,7 @@ pub(crate) fn is_sync_name(name: &str) -> bool { is_valid_note_name(name) } -fn scan_local(dir: &Path) -> Result { +fn scan_local(dir: &Path, io: &Mutex<()>) -> Result { let entries = fs::read_dir(dir) .map_err(|error| SyncError::Aborted(format!("无法读取笔记目录:{error}")))?; let mut scan = LocalScan::default(); @@ -378,7 +378,19 @@ fn scan_local(dir: &Path) -> Result { scan.skipped.insert(name); continue; } - let bytes = match fs::read(&path) { + // 与追加/改写共用一把锁:append_note 是多次 write,不锁的话可能 + // 读到半截文件并把它当成完整内容上传。 + let bytes = { + let _guard = io.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + fs::read(&path) + }; + let bytes = match bytes { + // 元数据检查之后文件可能又被追加过,读完再确认一次大小。 + Ok(bytes) if bytes.len() as u64 > MAX_FILE_BYTES => { + scan.warnings.push(format!("{name}:超过 2 MiB,已跳过")); + scan.skipped.insert(name); + continue; + } Ok(bytes) => bytes, Err(error) => { scan.warnings.push(format!("{name}:读取失败({error})")); @@ -466,7 +478,8 @@ impl SyncEngine { } let notes_dir = self.notes_dir.clone(); - let scan = match blocking(move || scan_local(¬es_dir)).await { + let io = self.io.clone(); + let scan = match blocking(move || scan_local(¬es_dir, &io)).await { Ok(Ok(scan)) => scan, Ok(Err(error)) => { summary.aborted = true; @@ -2341,7 +2354,7 @@ mod tests { let harness = Harness::new("scan-nonfile"); fs::create_dir_all(harness.notes.join("2026-10-23.md")).unwrap(); - let scan = scan_local(&harness.notes).unwrap(); + let scan = scan_local(&harness.notes, &Mutex::new(())).unwrap(); assert!(scan.skipped.contains("2026-10-23.md")); assert!(!scan.files.contains_key("2026-10-23.md")); } diff --git a/src-tauri/src/sync/secrets.rs b/src-tauri/src/sync/secrets.rs index 1f79b6d..ba98663 100644 --- a/src-tauri/src/sync/secrets.rs +++ b/src-tauri/src/sync/secrets.rs @@ -103,10 +103,21 @@ mod imp { } pub(super) fn clear(kind: SyncKind, url: &str, username: &str) -> Result<(), String> { - match entry(kind, url, username)?.delete_credential() { + let result = match entry(kind, url, username)?.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(error) => Err(format!("删除密码失败:{error}")), + }; + // 0.5.x 的旧账号名也要清掉,否则 load() 的迁移回退会把刚删掉的 + // 密码重新搬回来。 + if kind == SyncKind::Webdav { + let legacy_id = legacy_account(url, username); + if legacy_id != account(kind, url, username) { + if let Ok(legacy) = keyring::Entry::new(SERVICE, &legacy_id) { + let _ = legacy.delete_credential(); + } + } } + result } } diff --git a/src-tauri/src/sync/webdav.rs b/src-tauri/src/sync/webdav.rs index aa11ee1..ca2de5c 100644 --- a/src-tauri/src/sync/webdav.rs +++ b/src-tauri/src/sync/webdav.rs @@ -340,16 +340,18 @@ impl WebDavBackend { url: &Url, version: &RemoteVersion, sha256: Option<&str>, - ) -> Result<(), SyncError> { + ) -> Result, SyncError> { if let Some(expected) = sha256 { - let (bytes, _) = self.read_bytes(url.clone()).await?; + let (bytes, observed) = self.read_bytes(url.clone()).await?; if sha256_base64(&bytes) != expected { return Err(SyncError::VersionMismatch); } - return Ok(()); + // 回读顺带拿到的 ETag 可以用于接下来的 If-Match,把复核与写入 + // 之间的窗口关掉。 + return Ok(Some(observed)); } match self.stat_version(url).await { - Some(current) if ¤t == version => Ok(()), + Some(current) if ¤t == version => Ok(Some(current)), Some(_) => Err(SyncError::VersionMismatch), None => Err(SyncError::Unsupported( "服务器不支持条件写入,无法安全覆盖".into(), @@ -357,19 +359,41 @@ impl WebDavBackend { } } - /// 弱服务器写入后回读比对:内容被改写时重写一次,仍不一致就报错。 - async fn verify_write(&self, url: &Url, data: &[u8]) -> Result<(), SyncError> { + /// 弱服务器写入后回读比对。 + /// + /// 回读不一致时只有拿到这次写入的强 ETag 才敢补写;否则可能是另一台设备 + /// 在窗口期写了新版本,无条件重写会把它覆盖掉,宁可报版本冲突。 + async fn verify_write( + &self, + url: &Url, + data: &[u8], + written: Option<&RemoteVersion>, + ) -> Result<(), SyncError> { for attempt in 0..2 { match self.read_bytes(url.clone()).await { Ok((bytes, _)) if bytes == data => return Ok(()), Ok(_) if attempt == 0 => { + let Some(RemoteVersion::Etag(tag)) = written else { + return Err(SyncError::VersionMismatch); + }; + if !(self.caps.strong_etag && self.caps.honors_if_match) { + return Err(SyncError::VersionMismatch); + } self.request( Method::PUT, url.clone(), - vec![( - CONTENT_TYPE, - HeaderValue::from_static("text/markdown; charset=utf-8"), - )], + vec![ + ( + CONTENT_TYPE, + HeaderValue::from_static("text/markdown; charset=utf-8"), + ), + ( + IF_MATCH, + HeaderValue::from_str(tag).map_err(|error| { + SyncError::Other(format!("ETag 不合法:{error}")) + })?, + ), + ], Some(data.to_vec()), TRANSFER_TIMEOUT, ) @@ -632,8 +656,20 @@ impl SyncBackend for WebDavBackend { conditional = true; } Expect::Version { version, sha256 } => { - // 没有可用的 If-Match:先确认远端仍是预期内容,避免无条件覆盖。 - self.check_version(&url, version, sha256.as_deref()).await?; + // 记录里的版本是 Hash(PROPFIND 没给 ETag)时,先 GET 复核内容; + // 回读响应里的强 ETag 仍然可以用于接下来的 If-Match。 + let observed = self.check_version(&url, version, sha256.as_deref()).await?; + if self.caps.strong_etag && self.caps.honors_if_match { + if let Some(RemoteVersion::Etag(tag)) = observed { + headers.push(( + IF_MATCH, + HeaderValue::from_str(&tag).map_err(|error| { + SyncError::Other(format!("ETag 不合法:{error}")) + })?, + )); + conditional = true; + } + } } } @@ -680,7 +716,7 @@ impl SyncBackend for WebDavBackend { .map(RemoteVersion::Etag); if !conditional { - self.verify_write(&url, data).await?; + self.verify_write(&url, data, version.as_ref()).await?; } if let Some(version) = version { @@ -712,10 +748,21 @@ impl SyncBackend for WebDavBackend { } if !conditional { if let Expect::Version { version, sha256 } = &expect { - self.check_version(&url, version, sha256.as_deref()).await?; + let observed = self.check_version(&url, version, sha256.as_deref()).await?; + // 复核用的 GET 如果带回强 ETag,就把它升级成条件删除。 + if self.caps.strong_etag && self.caps.honors_if_match { + if let Some(RemoteVersion::Etag(tag)) = observed { + headers.push(( + IF_MATCH, + HeaderValue::from_str(&tag).map_err(|error| { + SyncError::Other(format!("ETag 不合法:{error}")) + })?, + )); + } + } } } - // 无论条件与否,这里都只剩 DELETE 本身(非条件路径的 GET 复核已经执行过)。 + // 条件路径只剩 DELETE 本身;非条件路径的 GET 复核已经执行过。 self.reserve_budget(1)?; let (response, _) = self .request(Method::DELETE, url, headers, None, REQUEST_TIMEOUT) @@ -1618,7 +1665,9 @@ mod tests { .put("2026-10-23.md", b"- 10:00 hi\n", Expect::Absent) .await .unwrap_err(); - assert!(matches!(error, SyncError::Unsupported(_))); + // 没有 ETag 就无法证明远端还是我们刚写的那一版,不能盲目重写; + // 报版本冲突,让引擎按合并/冲突路径处理。 + assert!(matches!(error, SyncError::VersionMismatch)); } #[tokio::test] diff --git a/src/history.ts b/src/history.ts index 8d95f41..b4e7b49 100644 --- a/src/history.ts +++ b/src/history.ts @@ -229,7 +229,9 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt cancel.addEventListener("click", close); + let saving = false; const commit = async (): Promise => { + if (saving) return; const value = editor.value.trim(); if (!value) { toast("内容不能为空", true); @@ -239,6 +241,7 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt close(); return; } + saving = true; save.disabled = true; try { const tags = await invoke("update_note_entry", { @@ -253,12 +256,20 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt note.content = value; // 标签由后端重新解析:正文改了标签就会变,不能用旧的 note.tags = tags; + // 保存期间又输入了内容:保持编辑器打开,别把新输入一起关掉。 + if (editor.value.trim() !== value) { + toast("已保存;还有新输入未保存", true); + save.disabled = false; + return; + } close(); toast("已更新"); renderNotes(); } catch (error) { save.disabled = false; toast(String(error), true); + } finally { + saving = false; } }; diff --git a/src/settings-panel.ts b/src/settings-panel.ts index 267dbd8..9ea9c46 100644 --- a/src/settings-panel.ts +++ b/src/settings-panel.ts @@ -103,6 +103,11 @@ export function handleRecordingKey(event: KeyboardEvent): void { } export function showSettingsPanel(): void { + // 面板已经打开时不要重填表单:用户可能刚输入了还没保存的地址、账号或密码。 + if (!ui.settingsPanel.classList.contains("hidden")) { + ui.settingsClose.focus(); + return; + } paintSettings(); paintSyncForm(); void refreshSyncStatus(); From 805f4b2df86d5f47d8975201ebc3a3621d2aa8ac Mon Sep 17 00:00:00 2001 From: weicheng <1648186705@qq.com> Date: Sun, 8 Nov 2026 18:47:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20review=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=9A=84=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E5=B9=B6=E8=AE=B0=E5=BD=95=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置面板重复打开保留未保存的同步表单 - 条目编辑保存期间的新输入与重复提交 - CHANGELOG 增加 Unreleased 修复记录 Co-Authored-By: Claude Code --- CHANGELOG.md | 16 ++++++++++++ src/main.dom.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++ src/settings-panel.ts | 6 ++++- 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1042442..c39124e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### 修复 + +- 同步:本地文件意外消失且远端版本令牌变化时,不再把远端唯一副本误删;「本地全部消失」安全阀把待恢复文件也计入。 +- 设置文件被杀软或同步盘短暂锁住时,本次运行不再用默认值覆盖原配置;读不出来时会提示「修改不会保存」。 +- OneDrive「按需文件」的云端占位符不再被误判为符号链接,保存与历史恢复正常。 +- Markdown 模式下正文里形如 `- 10:00`、`### 10:00` 的行不再被拆成新条目,删除幽灵条目也不会带走正文。 +- 托盘「打开今日笔记」拒绝符号链接;Git 自动提交只认真实 `.git` 目录,gitfile 重定向不再操作目录外仓库。 +- 笔记数量多时自动提交不再因 Windows 32K 命令行上限失败。 +- 合并结果超过 2 MiB 时改为保留冲突副本;同步警告会显示在状态行与悬停提示里。 +- 网络共享笔记目录的「定位」不再因 `\\?\UNC\` 前缀失败。 +- WebDAV 回读校验不再无条件重写;Hash 版本复核后用 GET 的强 ETag 做条件删除/写入,降低覆盖并发编辑的风险。 +- 条目编辑保存期间的新输入不再丢失;重开设置面板不再清空未保存的同步表单;跨 DPI 多屏恢复使用保存位置所在显示器的缩放。 +- 清除同步密码时一并删除 0.5.x 旧凭据,避免迁移回退把已清除的密码重新搬回来。 + ## [0.6.1] - 2026-11-04 ### 修复 diff --git a/src/main.dom.test.ts b/src/main.dom.test.ts index 0c6e55c..921fdfd 100644 --- a/src/main.dom.test.ts +++ b/src/main.dom.test.ts @@ -232,6 +232,41 @@ describe("编辑保护", () => { expect(reopened?.value).toBe(editorValue); }); + it("保存期间继续输入时保留编辑器,不丢掉新文字", async () => { + notes = [entry("09:00", "旧内容")]; + let finish: ((tags: string[]) => void) | undefined; + await boot({ + update_note_entry: () => + new Promise((resolve) => { + finish = resolve; + }), + }); + await openHistory(); + + editButton().click(); + await flush(); + const editor = node(".entry-edit"); + editor.value = "第一版"; + node(".entry-edit-actions .btn.primary").click(); + await flush(); + + // 保存还没返回时再按一次 Ctrl+Enter 不会重复提交 + editor.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }), + ); + await flush(); + expect( + mocks.invoke.mock.calls.filter(([cmd]) => cmd === "update_note_entry"), + ).toHaveLength(1); + + // 等待期间继续输入;保存返回后编辑器必须保持打开。 + editor.value = "第一版 + 新输入"; + finish?.([]); + await flush(); + + expect(node(".entry-edit").value).toBe("第一版 + 新输入"); + }); + it("撤销条初始不可聚焦", async () => { await boot(); expect(node("#undo-bar").hasAttribute("inert")).toBe(true); @@ -555,6 +590,29 @@ describe("云同步", () => { return call[1] as unknown as (event: { payload: TestSyncStatus }) => void; } + function openSettingsEvent(): void { + const call = mocks.listen.mock.calls.find(([name]) => name === "open-settings"); + if (!call) throw new Error("没有监听 open-settings"); + (call[1] as unknown as () => void)(); + } + + it("重新打开设置面板不会清空未保存的同步表单", async () => { + await boot(); + await openSettings(); + + node("#sync-url").value = "https://draft.example.com/"; + node("#sync-username").value = "draft-user"; + node("#sync-password").value = "draft-pass"; + + // 托盘「设置…」会再次走 openSettings(),不能重填表单 + openSettingsEvent(); + await flush(); + + expect(node("#sync-url").value).toBe("https://draft.example.com/"); + expect(node("#sync-username").value).toBe("draft-user"); + expect(node("#sync-password").value).toBe("draft-pass"); + }); + it("保存时提交表单,密码单独落盘并清空输入框", async () => { let saved: Record | undefined; await boot({ diff --git a/src/settings-panel.ts b/src/settings-panel.ts index 9ea9c46..0fdeb69 100644 --- a/src/settings-panel.ts +++ b/src/settings-panel.ts @@ -104,7 +104,11 @@ export function handleRecordingKey(event: KeyboardEvent): void { export function showSettingsPanel(): void { // 面板已经打开时不要重填表单:用户可能刚输入了还没保存的地址、账号或密码。 - if (!ui.settingsPanel.classList.contains("hidden")) { + // 正在播关闭动画(panel-leave)时不算,交给 showPanel 取消动画并重新打开。 + if ( + !ui.settingsPanel.classList.contains("hidden") && + !ui.settingsPanel.classList.contains("panel-leave") + ) { ui.settingsClose.focus(); return; } From 8332b89201ea509067668933b8b396bd65e96303 Mon Sep 17 00:00:00 2001 From: weicheng <1648186705@qq.com> Date: Sun, 8 Nov 2026 18:48:26 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=E8=AF=B4=E6=98=8E=20Markdown=20?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E7=A9=BA=E6=A0=BC=E4=BC=9A=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E4=B8=80=E4=B8=AA=E5=89=8D=E5=AF=BC=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- src-tauri/src/notes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 1783df4..ca2393a 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -387,8 +387,8 @@ fn needs_protection(line: &str, previous_blank: bool) -> bool { || (previous_blank && list_time(line).is_some()) } -/// 去掉写入时加的保护空格;只有去掉后确实像条目头才去,避免改动用户 -/// 自己缩进的内容。 +/// 去掉写入时加的保护空格;只有去掉后确实像条目头才去。 +/// 用户自己写的一个前导空格也会被归一化,但 Markdown 渲染效果不变。 fn strip_protection(line: &str, previous_blank: bool) -> &str { match line.strip_prefix(' ') { Some(rest) if needs_protection(rest, previous_blank) => rest, From 7837e349e505ecbe9bd640114433a5a9298d969d Mon Sep 17 00:00:00 2001 From: weicheng <1648186705@qq.com> Date: Sun, 8 Nov 2026 20:03:42 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20PR=20#112=20rev?= =?UTF-8?q?iew=20=E5=8F=91=E7=8E=B0=E7=9A=84=E5=9B=9E=E5=BD=92=E4=B8=8E?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Git check-attr 边写 stdin 边读 stdout,避免大笔记库死锁 - is_repo 拒绝带 commondir 的 .git,并用 rev-parse 校验工作区 - 打开今日笔记前先拒绝符号链接,避免 create_new 跟随悬空链接写到目录外 - 恢复 RestoreLocal 的「内容相同」去重:本地缺失按删除处理,删除前有 trash 备份 - 本地全消失时连 RestoreLocal 一起跳过,安全阀跨轮持续生效 - 同步扫描改用读前读后比对,不再持 io 锁阻塞保存 - WebDAV 回读补写检查状态;重定向后的 ETag 不再用于 If-Match - 设置读失败时回退到上次成功保存的副本,并只重试瞬时错误 - 编辑取消/保存中输入的显示一致性;设置面板重开只刷新状态 - 跨 DPI 恢复只缩放相对显示器原点的偏移 - Markdown 保护判定复用 entry_header,测试改用精确 HH:MM Co-Authored-By: Claude Code --- src-tauri/src/files.rs | 6 +- src-tauri/src/geometry.rs | 64 +++++++++++++++------- src-tauri/src/git_backup.rs | 81 +++++++++++++++++++++++---- src-tauri/src/notes.rs | 11 ++-- src-tauri/src/settings.rs | 99 ++++++++++++++++++++++++++------- src-tauri/src/sync/mod.rs | 103 ++++++++++++++++++++++++++--------- src-tauri/src/sync/webdav.rs | 66 ++++++++++++---------- src/history.ts | 27 ++++++--- src/settings-panel.ts | 4 +- src/sync.ts | 17 +++--- 10 files changed, 348 insertions(+), 130 deletions(-) diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 3357b67..dbbc3eb 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -164,6 +164,9 @@ fn open_with_default_app(path: &Path) -> Result<(), String> { fn ensure_note_file(dir: &Path, date: &str) -> Result { std::fs::create_dir_all(dir).map_err(|err| err.to_string())?; let path = dir.join(format!("{date}.md")); + // 必须先检查再 create_new:Windows 上 create_new 会跟随悬空的符号链接, + // 在链接目标处创建文件;等写完再拒绝,目录外已经被写入了。 + reject_reparse(&path).map_err(|err| format!("今日笔记不可用:{err}"))?; // create_new 而不是 exists + write:文件已存在时保持原样,不会截断当天笔记 match std::fs::OpenOptions::new() .write(true) @@ -178,8 +181,7 @@ fn ensure_note_file(dir: &Path, date: &str) -> Result { Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {} Err(err) => return Err(format!("创建今日笔记失败:{err}")), } - // 已存在时也要确认不是符号链接:托盘「打开今日笔记」会把它交给系统, - // 共享目录里预先放好的链接可能把 ShellExecuteW 引到目录外。 + // 创建后再检查一次,缩小检查与打开之间的竞态窗口。 reject_reparse(&path).map_err(|err| format!("今日笔记不可用:{err}"))?; Ok(path) } diff --git a/src-tauri/src/geometry.rs b/src-tauri/src/geometry.rs index 72f6083..a0d2e46 100644 --- a/src-tauri/src/geometry.rs +++ b/src-tauri/src/geometry.rs @@ -47,21 +47,25 @@ fn overlap_area(a: Rect, b: Rect) -> i64 { } } -/// 保存位置所在显示器当前的缩放。窗口刚创建时在主屏上, -/// `window.scale_factor()` 拿到的是主屏的,不能代表保存时那块屏。 -fn monitor_scale_at(window: &WebviewWindow, x: i32, y: i32) -> Option { +/// 保存位置所在显示器(完整矩形,不是工作区)及其当前缩放。 +/// 用工作区会把落在任务栏区域的坐标判成「没有显示器」。 +fn monitor_at(window: &WebviewWindow, x: i32, y: i32) -> Option<(Rect, f64)> { window .available_monitors() .ok()? .into_iter() - .find(|monitor| { - let area = monitor.work_area(); - x >= area.position.x - && x < area.position.x.saturating_add(area.size.width as i32) - && y >= area.position.y - && y < area.position.y.saturating_add(area.size.height as i32) + .find_map(|monitor| { + let position = monitor.position(); + let size = monitor.size(); + let rect = Rect { + x: position.x, + y: position.y, + w: size.width, + h: size.height, + }; + (x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()) + .then_some((rect, monitor.scale_factor())) }) - .map(|monitor| monitor.scale_factor()) } fn monitor_rects(window: &WebviewWindow) -> Vec { @@ -143,8 +147,14 @@ pub(crate) fn clamp_rect(rect: Rect, monitors: &[Rect]) -> (i32, i32) { } /// 把保存的物理像素几何从旧 DPI 缩放换算到当前缩放,保证逻辑位置/尺寸不变。 +/// 坐标只缩放相对显示器原点的偏移,否则副屏原点也会被乘一遍。 /// 老配置没有缩放字段(None)或数值异常时原样返回。 -pub(crate) fn scale_geometry(rect: Rect, saved_scale: Option, current_scale: f64) -> Rect { +pub(crate) fn scale_geometry( + rect: Rect, + saved_scale: Option, + current_scale: f64, + origin: (i32, i32), +) -> Rect { let factor = match saved_scale { Some(saved) if saved > 0.0 && current_scale > 0.0 => current_scale / saved, _ => return rect, @@ -153,8 +163,8 @@ pub(crate) fn scale_geometry(rect: Rect, saved_scale: Option, current_scale return rect; } - let point = |value: i32| -> i32 { - (value as f64 * factor) + let point = |value: i32, base: i32| -> i32 { + (base as f64 + (value as f64 - base as f64) * factor) .round() .clamp(i32::MIN as f64, i32::MAX as f64) as i32 }; @@ -162,8 +172,8 @@ pub(crate) fn scale_geometry(rect: Rect, saved_scale: Option, current_scale |value: u32| -> u32 { (value as f64 * factor).round().clamp(0.0, u32::MAX as f64) as u32 }; Rect { - x: point(rect.x), - y: point(rect.y), + x: point(rect.x, origin.0), + y: point(rect.y, origin.1), w: extent(rect.w), h: extent(rect.h), } @@ -229,8 +239,9 @@ pub fn apply_initial_geometry(app: &AppHandle) { // 窗口启动时在主屏,scale_factor() 是主屏的;保存的几何可能在另一块屏, // 必须用保存位置那块屏的当前缩放。 let current_scale = window.scale_factor().unwrap_or(1.0); - let target_scale = - monitor_scale_at(&window, geometry.x, geometry.y).unwrap_or(current_scale); + let (origin, target_scale) = monitor_at(&window, geometry.x, geometry.y) + .map(|(rect, scale)| ((rect.x, rect.y), scale)) + .unwrap_or(((0, 0), current_scale)); let scaled = scale_geometry( Rect { x: geometry.x, @@ -240,6 +251,7 @@ pub fn apply_initial_geometry(app: &AppHandle) { }, geometry.scale, target_scale, + origin, ); let (x, y) = clamp_rect(scaled, &monitors); let (w, h) = clamp_size(scaled.w, scaled.h, x, y, &monitors); @@ -393,17 +405,31 @@ mod tests { h: 360, }; - let scaled = scale_geometry(rect, Some(1.0), 1.5); + let scaled = scale_geometry(rect, Some(1.0), 1.5, (0, 0)); assert_eq!( (scaled.x, scaled.y, scaled.w, scaled.h), (150, 75, 720, 540) ); // 老配置没有 scale 字段:保持原样,不能凭空放大 - let untouched = scale_geometry(rect, None, 2.0); + let untouched = scale_geometry(rect, None, 2.0, (0, 0)); assert_eq!((untouched.x, untouched.y, untouched.w), (100, 50, 480)); } + #[test] + fn scale_geometry_keeps_the_monitor_origin_fixed() { + let rect = Rect { + x: 2000, + y: 100, + w: 480, + h: 360, + }; + // 副屏原点 1920 不参与缩放,只缩放窗口相对原点的偏移。 + let scaled = scale_geometry(rect, Some(1.0), 1.5, (1920, 0)); + assert_eq!((scaled.x, scaled.y), (2040, 150)); + assert_eq!((scaled.w, scaled.h), (720, 540)); + } + #[test] fn clamp_rect_survives_absurd_saved_size() { let monitors = [Rect { diff --git a/src-tauri/src/git_backup.rs b/src-tauri/src/git_backup.rs index e308fef..fc834cd 100644 --- a/src-tauri/src/git_backup.rs +++ b/src-tauri/src/git_backup.rs @@ -58,17 +58,41 @@ pub(crate) fn status(dir: &Path, last: &GitCommitState) -> GitStatus { } } -/// 笔记目录本身是一个普通仓库:`.git` 必须是真实目录。 +/// 笔记目录本身是一个普通仓库:`.git` 必须是真实目录,且不能是 linked worktree +/// 的 gitdir(带 `commondir`)。 /// -/// `.git` 也可以是 gitfile(内容 `gitdir: …`),但那会把提交引到另一个仓库。 -/// 共享/网络笔记目录里被放入这样一个文件时,自动提交就变成了对目录外仓库的写入。 +/// `.git` 是 gitfile(内容 `gitdir: …`)或带 `commondir` 的目录时,提交会被引到 +/// 另一个仓库;共享/网络笔记目录里被放入这样的结构,自动提交就成了对目录外仓库 +/// 的写入。worktree/submodule 因此不被支持——笔记目录应当是一个独立仓库。 pub(crate) fn is_repo(dir: &Path) -> bool { let git = dir.join(".git"); fs::symlink_metadata(&git) - .map(|meta| meta.is_dir() && !crate::files::is_reparse_point(&git)) + .map(|meta| { + meta.is_dir() + && !crate::files::is_reparse_point(&git) + && !git.join("commondir").exists() + }) .unwrap_or(false) } +/// 用 git 自己解析仓库根目录,确认就是笔记目录。 +/// +/// `is_repo` 只挡住了 gitfile/commondir;`.git/config` 里的 `core.worktree` +/// 仍可能把工作区指到别处。提交前做一次正向校验。 +fn repo_root_matches(dir: &Path) -> bool { + let Ok(out) = run(Some(dir), &["rev-parse", "--show-toplevel"]) else { + return false; + }; + if !out.status.success() { + return false; + } + let root = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let (Ok(root), Ok(dir)) = (fs::canonicalize(root), fs::canonicalize(dir)) else { + return false; + }; + root == dir +} + /// 系统里有没有 git。找不到时设置项直接禁用,而不是等保存后才报错。 pub(crate) fn available() -> bool { run(None, &["--version"]) @@ -104,6 +128,9 @@ pub(crate) fn commit(dir: &Path) -> Result { if !is_repo(dir) { return Err("笔记目录还不是 Git 仓库".to_string()); } + if !repo_root_matches(dir) { + return Err("笔记目录不是该 Git 仓库的工作区,已跳过自动提交".to_string()); + } let _guard = GIT_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -460,6 +487,9 @@ fn run_with_env( } /// 从标准输入喂路径,避开命令行长度上限。 +/// +/// 必须在写 stdin 的同时读 stdout:`check-attr` 会边读边输出,输出管道填满后 +/// git 停止读 stdin,而调用方还在写,双方互相等待,大笔记库会直接死锁。 fn run_with_stdin(dir: Option<&Path>, args: &[&str], input: &[u8]) -> Result { let mut child = git_command(dir, &[]) .args(args) @@ -468,14 +498,32 @@ fn run_with_stdin(dir: Option<&Path>, args: &[&str], input: &[u8]) -> Result Result<(), String> { + if let Some(stdin) = stdin.as_mut() { + stdin + .write_all(input) + .map_err(|error| format!("向 git 写入路径失败:{error}"))?; + } + Ok(()) + }); + let output = child + .wait_with_output() + .map_err(|error| format!("等待 git 失败:{error}")); + let write_result = writer + .join() + .unwrap_or_else(|_| Err("向 git 写入路径的线程异常退出".to_string())); + (write_result, output) + }); + if let Err(err) = write_result { + // git 提前失败时 stdin 会断开,真正的错误在 stderr 里,交给调用方; + // 但如果 git 报成功、路径却没写完,这个成功不能信。 + if output.as_ref().is_ok_and(|out| out.status.success()) { + return Err(err); + } + } + output } /// git 的报错主要在 stderr;有些版本也会写 stdout。 @@ -509,6 +557,15 @@ mod tests { assert!(init(&dir).is_err()); } + #[test] + fn commondir_git_directory_is_not_treated_as_a_repository() { + let dir = temp_dir("commondir"); + fs::create_dir_all(dir.join(".git")).unwrap(); + fs::write(dir.join(".git").join("commondir"), "../elsewhere").unwrap(); + assert!(!is_repo(&dir)); + assert!(init(&dir).is_err()); + } + #[test] fn init_creates_a_repo_only_once() { if !available() { diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index ca2393a..3c89056 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -380,11 +380,10 @@ fn list_time(line: &str) -> Option<(String, String)> { is_hhmm(time).then(|| (time.to_string(), first.to_string())) } -/// 这行在 Markdown 正文里会不会被当成条目头。写入时加一个前导空格保护: -/// `### HH:MM` 任何时候都算条目头;`- HH:MM` 只在空行之后算。 +/// 这行在 Markdown 正文里会不会被当成条目头。写入时加一个前导空格保护。 +/// 直接复用 `entry_header`,保证写入与解析永远用同一套判定。 fn needs_protection(line: &str, previous_blank: bool) -> bool { - (!line.starts_with(char::is_whitespace) && markdown_time(line).is_some()) - || (previous_blank && list_time(line).is_some()) + entry_header(line, true, previous_blank).is_some() } /// 去掉写入时加的保护空格;只有去掉后确实像条目头才去。 @@ -1089,7 +1088,7 @@ mod tests { #[test] fn markdown_time_like_body_lines_round_trip_as_one_entry() { - let content = "会议安排\n\n- 10:00 开场\n- 11:00 讨论\n\n### 12:00 午餐"; + let content = "会议安排\n\n- 10:00 开场\n- 11:00 讨论\n\n### 12:00"; let formatted = format_entry_markdown("09:00", content, None); let entries = parse_entries(&format!("# 2026-10-21\n\n{formatted}\n")); assert_eq!(entries.len(), 1); @@ -1099,7 +1098,7 @@ mod tests { #[test] fn markdown_body_heading_like_first_line_round_trips() { - let content = "### 12:00 午餐\n正文"; + let content = "### 12:00\n正文"; let formatted = format_entry_markdown("09:00", content, None); let entries = parse_entries(&format!("# 2026-10-21\n\n{formatted}\n")); assert_eq!(entries.len(), 1); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 81952ea..9dad2a0 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -205,22 +205,44 @@ pub fn load(app: &AppHandle) -> LoadedSettings { loaded } +/// 只有这类错误值得重试:杀软/同步盘短暂锁住文件。 +fn transient_read_error(kind: io::ErrorKind) -> bool { + matches!( + kind, + io::ErrorKind::PermissionDenied + | io::ErrorKind::WouldBlock + | io::ErrorKind::Interrupted + | io::ErrorKind::TimedOut + ) +} + /// 读设置文件。杀软或同步盘可能短暂锁住文件,重试几次再放弃。 fn read_settings_file(path: &Path) -> io::Result { - let mut last = None; - for attempt in 0..3 { + let mut attempt = 0; + loop { match fs::read_to_string(path) { Ok(raw) => return Ok(raw), Err(err) if err.kind() == io::ErrorKind::NotFound => return Err(err), - Err(err) => { - last = Some(err); - if attempt < 2 { - std::thread::sleep(Duration::from_millis(80)); - } + Err(err) if transient_read_error(err.kind()) && attempt < 2 => { + attempt += 1; + std::thread::sleep(Duration::from_millis(80)); } + Err(err) => return Err(err), } } - Err(last.unwrap_or_else(|| io::Error::other("读取设置文件失败"))) +} + +fn normalize_settings(mut settings: Settings) -> Settings { + settings.theme = normalize_theme(&settings.theme); + settings.background_opacity = clamp_opacity(settings.background_opacity); + settings.sync.interval_minutes = + SyncSettings::normalize_interval(settings.sync.interval_minutes); + settings +} + +/// 上次成功保存的副本。主文件被杀软/同步盘锁住或写坏时用它恢复配置。 +fn good_path(path: &Path) -> PathBuf { + path.with_file_name("settings.json.good") } /// 读取指定路径的设置文件。损坏时先备份原文件,再返回默认值。 @@ -231,13 +253,7 @@ pub(crate) fn load_from_path(path: &Path) -> LoadedSettings { let mut settings = match read_settings_file(path) { Ok(raw) => match parse_settings(&raw) { // 手工编辑出的非法值只纠正字段,不把整份设置当成损坏 - Ok(mut parsed) => { - parsed.theme = normalize_theme(&parsed.theme); - parsed.background_opacity = clamp_opacity(parsed.background_opacity); - parsed.sync.interval_minutes = - SyncSettings::normalize_interval(parsed.sync.interval_minutes); - parsed - } + Ok(parsed) => normalize_settings(parsed), Err(err) => { log::error!("设置文件解析失败({}):{err}", path.display()); warning = Some(match backup_corrupt(path) { @@ -256,11 +272,27 @@ pub(crate) fn load_from_path(path: &Path) -> LoadedSettings { Err(err) if err.kind() == io::ErrorKind::NotFound => Settings::default(), Err(err) => { log::error!("读取设置文件失败({}):{err}", path.display()); - // 读不出来 ≠ 没有设置。用默认值继续运行,但本次运行不能写回, - // 否则一次瞬时锁文件就会把笔记目录、快捷键和云同步配置全部抹掉。 + // 读不出来 ≠ 没有设置。优先用上次成功保存的副本,至少笔记目录和 + // 同步配置是对的;本次运行不写回,避免覆盖仍然有效的原文件。 read_failed = true; - warning = Some("设置文件暂时无法读取,本次运行的修改不会保存;请重启应用".to_string()); - recovered_default() + match read_settings_file(&good_path(path)) + .and_then(|raw| parse_settings(&raw).map_err(io::Error::other)) + { + Ok(parsed) => { + warning = Some( + "设置文件暂时无法读取,已使用上次成功保存的配置;本次运行的修改不会保存" + .to_string(), + ); + normalize_settings(parsed) + } + Err(_) => { + warning = Some( + "设置文件暂时无法读取,本次运行使用默认设置且不会保存;请检查文件权限后重启" + .to_string(), + ); + recovered_default() + } + } } }; @@ -422,7 +454,12 @@ pub fn save(path: &Path, settings: &Settings) -> std::io::Result<()> { } let data = serde_json::to_vec_pretty(settings) .map_err(|err| std::io::Error::other(err.to_string()))?; - crate::files::write_atomic(path, &data) + crate::files::write_atomic(path, &data)?; + // 上次成功保存的副本:主文件读不出来时用它恢复配置。 + if let Err(err) = crate::files::write_atomic(&good_path(path), &data) { + log::warn!("写入设置备份失败:{err}"); + } + Ok(()) } #[cfg(test)] @@ -632,6 +669,28 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn good_copy_is_used_when_the_main_file_cannot_be_read() { + let dir = temp_dir("good-copy"); + let path = dir.join("settings.json"); + let settings = Settings { + notes_dir: "D:\\GoodNotes".to_string(), + hotkey: "ctrl+shift+g".to_string(), + ..Settings::default() + }; + save(&path, &settings).unwrap(); + + // 主文件变成非 UTF-8:读不出来,但上次成功保存的副本还在。 + fs::write(&path, [0xff, 0xfe, 0x00]).unwrap(); + let loaded = load_from_path(&path); + assert!(loaded.read_failed, "读失败时本次运行不能写回"); + assert_eq!(loaded.settings.notes_dir, "D:\\GoodNotes"); + assert_eq!(loaded.settings.hotkey, "ctrl+shift+g"); + assert!(loaded.warning.is_some()); + + let _ = fs::remove_dir_all(&dir); + } + #[test] fn partial_file_falls_back_to_defaults() { let dir = temp_dir("partial"); diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index f994168..5cee59b 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -331,7 +331,21 @@ pub(crate) fn is_sync_name(name: &str) -> bool { is_valid_note_name(name) } -fn scan_local(dir: &Path, io: &Mutex<()>) -> Result { +/// 读一份稳定的快照:读前读后比对大小与 mtime,避免把追加中的半截文件 +/// 当成完整内容上传。两次都不稳定就返回 None(本轮跳过)。 +fn read_stable(path: &Path) -> std::io::Result>> { + for _ in 0..2 { + let before = fs::metadata(path)?; + let bytes = fs::read(path)?; + let after = fs::metadata(path)?; + if before.len() == after.len() && before.modified().ok() == after.modified().ok() { + return Ok(Some(bytes)); + } + } + Ok(None) +} + +fn scan_local(dir: &Path) -> Result { let entries = fs::read_dir(dir) .map_err(|error| SyncError::Aborted(format!("无法读取笔记目录:{error}")))?; let mut scan = LocalScan::default(); @@ -378,20 +392,22 @@ fn scan_local(dir: &Path, io: &Mutex<()>) -> Result { scan.skipped.insert(name); continue; } - // 与追加/改写共用一把锁:append_note 是多次 write,不锁的话可能 - // 读到半截文件并把它当成完整内容上传。 - let bytes = { - let _guard = io.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - fs::read(&path) - }; - let bytes = match bytes { + // 不持 io 锁:锁会跨过 fs::read,OneDrive 水合或网络盘掉线时 + // 会把所有保存都堵住。改用读前读后比对,半截文件直接跳过。 + let bytes = match read_stable(&path) { // 元数据检查之后文件可能又被追加过,读完再确认一次大小。 - Ok(bytes) if bytes.len() as u64 > MAX_FILE_BYTES => { + Ok(Some(bytes)) if bytes.len() as u64 > MAX_FILE_BYTES => { scan.warnings.push(format!("{name}:超过 2 MiB,已跳过")); scan.skipped.insert(name); continue; } - Ok(bytes) => bytes, + Ok(Some(bytes)) => bytes, + Ok(None) => { + scan.warnings + .push(format!("{name}:文件在扫描期间被修改,已跳过")); + scan.skipped.insert(name); + continue; + } Err(error) => { scan.warnings.push(format!("{name}:读取失败({error})")); scan.skipped.insert(name); @@ -478,8 +494,7 @@ impl SyncEngine { } let notes_dir = self.notes_dir.clone(); - let io = self.io.clone(); - let scan = match blocking(move || scan_local(¬es_dir, &io)).await { + let scan = match blocking(move || scan_local(¬es_dir)).await { Ok(Ok(scan)) => scan, Ok(Err(error)) => { summary.aborted = true; @@ -595,10 +610,14 @@ impl SyncEngine { "安全阀", format!("本轮拟删除 {deletions} 个文件(共 {tracked} 个已同步),已跳过删除"), ); + // 「本地全没了」时连 RestoreLocal 也跳过:否则恢复回来的几篇会让 + // 下一轮不再满足 all_missing,剩下的远端文件照样被删。目录保持为空, + // 安全阀会一直生效,直到用户把目录恢复。 plans = plans .into_iter() .map(|(name, action)| match action { Action::DeleteRemote | Action::TrashLocal => (name, Action::Skip), + Action::RestoreLocal if all_missing => (name, Action::Skip), other => (name, other), }) .collect(); @@ -761,12 +780,11 @@ impl SyncEngine { } // 版本令牌可能来自 GET 与 PROPFIND 两个来源。内容与我们上次 // 同步的完全一致时,只把列表版本补进记录,避免反复下载。 - // 仅限 Download:RestoreLocal 表示本地文件已经不存在,必须 - // 先把内容写回去,否则下一轮会把它当成用户删除而删掉远端。 - if matches!(action, Action::Download) - && record - .as_ref() - .is_some_and(|record| sha256_base64(&bytes) == record.remote_sha256) + // 内容没变而本地文件不存在,按「用户删除」处理:下一轮会 + // 正常传播删除(删除前远端副本会先备份进 trash)。 + if record + .as_ref() + .is_some_and(|record| sha256_base64(&bytes) == record.remote_sha256) { let version = remote.map(|meta| meta.version.clone()).unwrap_or(version); self.commit(name, &bytes, version, summary).await; @@ -1419,6 +1437,16 @@ mod tests { return Err(SyncError::VersionMismatch); } } + // 列表版本是 Hash 时,真实后端会先 GET 比对摘要;这里同样 + // 按 sha256 校验,模拟弱服务器。 + Some((bytes, _)) if matches!(version, RemoteVersion::Hash(_)) => { + if sha256 + .as_deref() + .is_some_and(|expected| sha256_base64(bytes) != expected) + { + return Err(SyncError::VersionMismatch); + } + } _ => return Err(SyncError::VersionMismatch), }, _ => {} @@ -1442,6 +1470,15 @@ mod tests { return Err(SyncError::VersionMismatch); } } + // Hash 版本按内容摘要校验,和真实 WebDAV 后端的弱服务器路径一致。 + Some((bytes, _)) if matches!(version, RemoteVersion::Hash(_)) => { + if sha256 + .as_deref() + .is_some_and(|expected| sha256_base64(bytes) != expected) + { + return Err(SyncError::VersionMismatch); + } + } _ => return Err(SyncError::VersionMismatch), } } @@ -1709,25 +1746,26 @@ mod tests { } #[test] - fn restore_local_writes_the_file_instead_of_recording_a_missing_one() { + fn restore_local_with_identical_content_still_propagates_deletion() { let backend = FakeBackend::strong(); let harness = Harness::with_backend("restore-local-dedupe", backend.clone()); let text = "# 2026-10-23\n\n- 10:00 a\n"; harness.write("2026-10-23.md", text); assert_eq!(harness.run().uploaded, 1); - // 本地文件意外消失,同时远端版本令牌漂移但内容没变。 + // 本地删除,同时远端版本令牌漂移但内容没变:内容没变就不算远端修改, + // 本地缺失按用户删除处理。 fs::remove_file(harness.notes.join("2026-10-23.md")).unwrap(); backend.diverge_list_versions(); let summary = harness.run(); - assert_eq!(summary.downloaded, 1, "{summary:?}"); - assert_eq!(harness.read("2026-10-23.md"), text); + assert_eq!(summary.skipped, 1, "{summary:?}"); + assert!(!harness.notes.join("2026-10-23.md").exists()); - // 下一轮不能把远端唯一副本当成用户删除。 + // 下一轮把删除传播到远端(删除前会备份到 trash)。 let summary = harness.run(); - assert_eq!(summary.deleted, 0, "{summary:?}"); - assert!(backend.content("2026-10-23.md").is_some()); + assert_eq!(summary.deleted, 1, "{summary:?}"); + assert!(backend.content("2026-10-23.md").is_none()); } #[test] @@ -1917,9 +1955,22 @@ mod tests { "{summary:?}" ); assert_eq!(summary.deleted, 0, "{summary:?}"); + // RestoreLocal 也被拦住:目录保持为空,安全阀下一轮仍然生效。 + assert!(!harness.notes.join("2026-10-01.md").exists()); + assert!(!harness.notes.join("2026-10-02.md").exists()); // 未变的远端文件不能被删。 assert!(backend.content("2026-10-03.md").is_some()); assert!(backend.content("2026-10-04.md").is_some()); + + let summary = harness.run(); + assert!( + summary.errors.iter().any(|error| error.contains("安全阀")), + "{summary:?}" + ); + assert_eq!(summary.deleted, 0, "{summary:?}"); + for day in 1..=4 { + assert!(backend.content(&format!("2026-10-{day:02}.md")).is_some()); + } } #[test] @@ -2354,7 +2405,7 @@ mod tests { let harness = Harness::new("scan-nonfile"); fs::create_dir_all(harness.notes.join("2026-10-23.md")).unwrap(); - let scan = scan_local(&harness.notes, &Mutex::new(())).unwrap(); + let scan = scan_local(&harness.notes).unwrap(); assert!(scan.skipped.contains("2026-10-23.md")); assert!(!scan.files.contains_key("2026-10-23.md")); } diff --git a/src-tauri/src/sync/webdav.rs b/src-tauri/src/sync/webdav.rs index ca2de5c..f38db99 100644 --- a/src-tauri/src/sync/webdav.rs +++ b/src-tauri/src/sync/webdav.rs @@ -208,7 +208,7 @@ impl WebDavBackend { let readback_ok = matches!( self.read_bytes(probe_url.clone()).await, - Ok((bytes, _)) if bytes == body + Ok((bytes, _, _)) if bytes == body ); // 探测文件尽力清理,失败不影响能力判断。 @@ -286,8 +286,8 @@ impl WebDavBackend { Ok((text, final_url)) } - async fn read_bytes(&self, url: Url) -> Result<(Vec, RemoteVersion), SyncError> { - let (mut response, _) = self + async fn read_bytes(&self, url: Url) -> Result<(Vec, RemoteVersion, Url), SyncError> { + let (mut response, final_url) = self .request(Method::GET, url, Vec::new(), None, TRANSFER_TIMEOUT) .await?; if response.status() == StatusCode::NOT_FOUND { @@ -317,7 +317,7 @@ impl WebDavBackend { } bytes.extend_from_slice(&chunk); } - Ok((bytes, version)) + Ok((bytes, version, final_url)) } /// Depth: 0 读单个文件的版本;服务器不支持时返回 None。 @@ -342,13 +342,14 @@ impl WebDavBackend { sha256: Option<&str>, ) -> Result, SyncError> { if let Some(expected) = sha256 { - let (bytes, observed) = self.read_bytes(url.clone()).await?; + let (bytes, observed, final_url) = self.read_bytes(url.clone()).await?; if sha256_base64(&bytes) != expected { return Err(SyncError::VersionMismatch); } - // 回读顺带拿到的 ETag 可以用于接下来的 If-Match,把复核与写入 - // 之间的窗口关掉。 - return Ok(Some(observed)); + // 只有 GET 没有被重定向时,响应里的 ETag 才属于这个 URL; + // 重定向到 CDN/预签名地址时,ETag 可能对应另一份表示,不能 + // 拿来当 If-Match。 + return Ok((final_url == *url).then_some(observed)); } match self.stat_version(url).await { Some(current) if ¤t == version => Ok(Some(current)), @@ -371,7 +372,7 @@ impl WebDavBackend { ) -> Result<(), SyncError> { for attempt in 0..2 { match self.read_bytes(url.clone()).await { - Ok((bytes, _)) if bytes == data => return Ok(()), + Ok((bytes, _, _)) if bytes == data => return Ok(()), Ok(_) if attempt == 0 => { let Some(RemoteVersion::Etag(tag)) = written else { return Err(SyncError::VersionMismatch); @@ -379,25 +380,32 @@ impl WebDavBackend { if !(self.caps.strong_etag && self.caps.honors_if_match) { return Err(SyncError::VersionMismatch); } - self.request( - Method::PUT, - url.clone(), - vec![ - ( - CONTENT_TYPE, - HeaderValue::from_static("text/markdown; charset=utf-8"), - ), - ( - IF_MATCH, - HeaderValue::from_str(tag).map_err(|error| { - SyncError::Other(format!("ETag 不合法:{error}")) - })?, - ), - ], - Some(data.to_vec()), - TRANSFER_TIMEOUT, - ) - .await?; + let response = self + .request( + Method::PUT, + url.clone(), + vec![ + ( + CONTENT_TYPE, + HeaderValue::from_static("text/markdown; charset=utf-8"), + ), + ( + IF_MATCH, + HeaderValue::from_str(tag).map_err(|error| { + SyncError::Other(format!("ETag 不合法:{error}")) + })?, + ), + ], + Some(data.to_vec()), + TRANSFER_TIMEOUT, + ) + .await? + .0; + // 补写也可能是 412(另一台设备抢先写入)、401 或 507: + // 不能吞掉状态,否则会把版本冲突误报成「服务器改写内容」。 + if !response.status().is_success() { + return Err(map_status("上传", &response)); + } } Ok(_) => {} Err(SyncError::NotFound) => {} @@ -593,7 +601,7 @@ impl SyncBackend for WebDavBackend { async fn get(&self, name: &str) -> Result, RemoteVersion)>, SyncError> { let url = self.file_url(name)?; match self.read_bytes(url).await { - Ok(value) => Ok(Some(value)), + Ok((bytes, version, _)) => Ok(Some((bytes, version))), Err(SyncError::NotFound) => Ok(None), Err(error) => Err(error), } diff --git a/src/history.ts b/src/history.ts index b4e7b49..7d0fd25 100644 --- a/src/history.ts +++ b/src/history.ts @@ -221,17 +221,30 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt cancel.className = "btn tiny"; cancel.textContent = "取消"; + let savedWhileEditing = false; const close = (): void => { wrap.replaceWith(contentEl); contentEl.dataset.editing = ""; + if (savedWhileEditing) { + // 保存成功但编辑器里还有新输入,用户最终选择取消:按已保存的内容 + // 重渲列表,否则会一直显示保存前的旧文本。 + savedWhileEditing = false; + renderNotes(); + document.querySelector(".entry-action")?.focus(); + return; + } editButton.focus(); }; - cancel.addEventListener("click", close); + cancel.addEventListener("click", () => { + // 保存请求还在飞:等它回来再关,避免把等待期间输入的文字一起丢掉。 + if (save.disabled) return; + close(); + }); - let saving = false; const commit = async (): Promise => { - if (saving) return; + // 用按钮的禁用态作为唯一的「保存中」标记,避免两个状态不一致。 + if (save.disabled) return; const value = editor.value.trim(); if (!value) { toast("内容不能为空", true); @@ -241,7 +254,6 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt close(); return; } - saving = true; save.disabled = true; try { const tags = await invoke("update_note_entry", { @@ -258,18 +270,18 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt note.tags = tags; // 保存期间又输入了内容:保持编辑器打开,别把新输入一起关掉。 if (editor.value.trim() !== value) { + savedWhileEditing = true; toast("已保存;还有新输入未保存", true); save.disabled = false; return; } + savedWhileEditing = false; close(); toast("已更新"); renderNotes(); } catch (error) { save.disabled = false; toast(String(error), true); - } finally { - saving = false; } }; @@ -280,7 +292,8 @@ function startEdit(note: NoteEntry, contentEl: HTMLElement, editButton: HTMLButt if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); - close(); + // 保存中不关闭:请求回来时编辑器还在,等待期间输入的文字才不会丢。 + if (!save.disabled) close(); return; } if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { diff --git a/src/settings-panel.ts b/src/settings-panel.ts index 0fdeb69..1c2e464 100644 --- a/src/settings-panel.ts +++ b/src/settings-panel.ts @@ -109,7 +109,9 @@ export function showSettingsPanel(): void { !ui.settingsPanel.classList.contains("hidden") && !ui.settingsPanel.classList.contains("panel-leave") ) { - ui.settingsClose.focus(); + // 只刷新状态,不重填表单,也不抢焦点(用户可能正在输入)。 + void refreshSyncStatus(); + void paintGitStatus(); return; } paintSettings(); diff --git a/src/sync.ts b/src/sync.ts index 5514c94..16313ec 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -118,6 +118,13 @@ function paintSyncIndicator(status: SyncStatus): void { } } +/** 上一轮明细/警告是否应该显示:未配置、关闭、同步中或有错误时都不显示。 */ +function summaryVisible(status: SyncStatus): boolean { + return Boolean( + status.summary && status.configured && status.enabled && !status.running && !status.lastError, + ); +} + /** 一句话状态:设置区显示完整版,历史面板只显示第一段。 */ export function describeStatus(status: SyncStatus): string[] { const parts: string[] = []; @@ -139,7 +146,7 @@ export function describeStatus(status: SyncStatus): string[] { // 只有启用中的配置才展示上一轮明细,否则「未配置」旁边挂一串数字很误导 const summary = status.summary; - if (summary && status.configured && status.enabled && !status.running && !status.lastError) { + if (summary && summaryVisible(status)) { const changed = summary.uploaded + summary.downloaded + summary.merged + summary.deleted; if (changed > 0) { @@ -170,13 +177,7 @@ export function paintSyncStatus(status: SyncStatus): void { ui.syncError.textContent = ""; } - const hasWarnings = Boolean( - status.summary?.warnings.length && - status.configured && - status.enabled && - !status.running && - !status.lastError, - ); + const hasWarnings = Boolean(status.summary?.warnings.length && summaryVisible(status)); ui.syncStatusText.textContent = describeStatus(status).join(" · "); ui.syncStatusText.classList.toggle("warn", Boolean(status.degraded) || hasWarnings); ui.syncStatusText.classList.toggle("bad", Boolean(status.lastError));