@@ -90,6 +90,15 @@ pub struct MarkdownDiffOps {
9090 pub removed : usize ,
9191}
9292
93+ /// 行级 diff 入口字符护栏上限(两段文本合计字符数)。
94+ ///
95+ /// @ai-context Why(P2-6 审查修复):note_diff 仅按「行数乘积 4M」回退
96+ /// (LCS_CELLS_MAX),无字符级上限——单行超长文本的行数乘积
97+ /// 1×1 不触发回退,但行比较/克隆/ops 序列化与 JSON 传输仍随
98+ /// 总字符数线性放大;2M 字符 ≈ 常规精修/笔记量级(万字符内)
99+ /// 两个数量级以上的余量,超限即视为异常输入走降级。
100+ const DIFF_MD_CHARS_MAX : usize = 2_000_000 ;
101+
93102/// 任意两篇 markdown 的整篇有序行级 diff(精修工作台行级标色/差异模式)。
94103///
95104/// @ai-context Why(批 3 / 用户问题11):工作台此前只有章节分组
@@ -98,11 +107,37 @@ pub struct MarkdownDiffOps {
98107/// 补薄命令直接暴露 note_diff::diff_markdown 全文档流(纯函数
99108/// 复用,无第二套 diff 引擎);前端三入口(会话级/笔记级/只读)
100109/// 统一经本命令对"实际展示的两版文本"取数,保证行与渲染对齐。
110+ /// @ai-context Why(P2-6 审查修复):改 async——Tauri 同步 command 在主线程
111+ /// 执行,超长文本的 diff 会卡 UI;async 命令派发到异步运行时
112+ /// 线程执行(仓库先例:commands.rs list_notes/proofread_run 等
113+ /// async 命令同步体,无 await 点直接返回)。计算核心无 await
114+ /// 点、CPU 量已被 DIFF_MD_CHARS_MAX + note_diff 行数乘积 4M 回退
115+ /// 双向封顶——同步算完即返即可,无需 spawn_blocking。
101116#[ tauri:: command]
102- pub fn diff_markdown_ops ( old_md : String , new_md : String ) -> MarkdownDiffOps {
103- let ops = diff_markdown ( & old_md, & new_md) ;
117+ pub async fn diff_markdown_ops (
118+ old_md : String ,
119+ new_md : String ,
120+ ) -> Result < MarkdownDiffOps , String > {
121+ diff_markdown_ops_inner ( & old_md, & new_md)
122+ }
123+
124+ /// diff_markdown_ops 同步计算核心(纯函数;单测直接调用,不依赖运行时)。
125+ ///
126+ /// 降级口径(P2-6):超限返回 Err 而非空 ops——前端 RefineWorkbench.load
127+ /// 对本命令取数失败 catch(() => null) → ops=null → mdFallbackRows 全文本
128+ /// 不染色兜底(refineDiff.ts 既有「取数失败→不染色」路径);空数组在 JS
129+ /// 是 truthy,splitDiffSides([]) 会让工作台双栏渲染成空白——不可用空 ops
130+ /// 表达降级(已核对前端对空/失败的消费,走失败路径)。
131+ fn diff_markdown_ops_inner ( old_md : & str , new_md : & str ) -> Result < MarkdownDiffOps , String > {
132+ if old_md. chars ( ) . count ( ) + new_md. chars ( ) . count ( ) > DIFF_MD_CHARS_MAX {
133+ return Err ( format ! (
134+ "内容过长(两段合计超 {} 字符)——跳过行级染色,原文不受影响" ,
135+ DIFF_MD_CHARS_MAX
136+ ) ) ;
137+ }
138+ let ops = diff_markdown ( old_md, new_md) ;
104139 let ( added, removed, _) = diff_stats ( & ops) ;
105- MarkdownDiffOps { ops, added, removed }
140+ Ok ( MarkdownDiffOps { ops, added, removed } )
106141}
107142
108143/// 读版本并校验归属(diff 输入防御)。
@@ -130,7 +165,7 @@ mod tests {
130165 let old_md = "甲\n 删我\n 尾" . to_string ( ) ;
131166 let new_md = "甲\n 加我\n 尾" . to_string ( ) ;
132167 // Act
133- let out = diff_markdown_ops ( old_md, new_md) ;
168+ let out = diff_markdown_ops_inner ( & old_md, & new_md) . expect ( "正常输入应成功" ) ;
134169 // Assert:三态各自一个且有序(unchanged → removed → added)
135170 assert_eq ! (
136171 out. ops,
@@ -147,14 +182,15 @@ mod tests {
147182 /// AAA:空侧边界——单侧全量标记,另一侧无行。
148183 #[ test]
149184 fn diff_markdown_ops_empty_side_counts_every_line ( ) {
150- // Arrange
151- let out = diff_markdown_ops ( String :: new ( ) , "新增1\n 新增2" . to_string ( ) ) ;
152- // Act/Assert:空基线的整篇都是新增
185+ // Arrange/Act
186+ let out =
187+ diff_markdown_ops_inner ( "" , "新增1\n 新增2" ) . expect ( "正常输入应成功" ) ;
188+ // Assert:空基线的整篇都是新增
153189 assert_eq ! ( out. ops. len( ) , 2 ) ;
154190 assert ! ( out. ops. iter( ) . all( |o| matches!( o, DiffOp :: Added ( _) ) ) ) ;
155191 assert_eq ! ( ( out. added, out. removed) , ( 2 , 0 ) ) ;
156192
157- let out = diff_markdown_ops ( "删1\n 删2" . to_string ( ) , String :: new ( ) ) ;
193+ let out = diff_markdown_ops_inner ( "删1\n 删2" , "" ) . expect ( "正常输入应成功" ) ;
158194 // Assert:空精修版的整篇都是删除
159195 assert ! ( out. ops. iter( ) . all( |o| matches!( o, DiffOp :: Removed ( _) ) ) ) ;
160196 assert_eq ! ( ( out. added, out. removed) , ( 0 , 2 ) ) ;
@@ -165,7 +201,8 @@ mod tests {
165201 #[ test]
166202 fn diff_markdown_ops_serializes_camel_case_and_lowercase_tags ( ) {
167203 // Arrange/Act
168- let out = diff_markdown_ops ( "旧\n 同" . to_string ( ) , "新\n 同" . to_string ( ) ) ;
204+ let out =
205+ diff_markdown_ops_inner ( "旧\n 同" , "新\n 同" ) . expect ( "正常输入应成功" ) ;
169206 let json = serde_json:: to_value ( & out) . unwrap ( ) ;
170207 // Assert:顶层键 camelCase(ops/added/removed)
171208 assert ! ( json. get( "ops" ) . is_some( ) ) ;
@@ -179,4 +216,34 @@ mod tests {
179216 assert_eq ! ( json[ "added" ] , 1 ) ;
180217 assert_eq ! ( json[ "removed" ] , 1 ) ;
181218 }
219+
220+ /// AAA(P2-6):字符护栏——两段合计超 DIFF_MD_CHARS_MAX → Err 降级,
221+ /// 不进入 diff 计算。选型:单行超长文本行数乘积 1×1,note_diff 的 4M
222+ /// 行数回退拦不住,唯独入口字符护栏能拦下。
223+ #[ test]
224+ fn diff_markdown_ops_over_char_limit_returns_err_degrade ( ) {
225+ // Arrange:old 距上限差 1 字符 + new 两字符 = 合计刚好超 1 字符
226+ let old_md = "甲" . repeat ( DIFF_MD_CHARS_MAX - 1 ) ;
227+ let new_md = "乙丙" . to_string ( ) ;
228+ // Act
229+ let res = diff_markdown_ops_inner ( & old_md, & new_md) ;
230+ // Assert:Err(前端按取数失败降级为全文本不染色)且报错可读
231+ let err = res. expect_err ( "超限应返回 Err 降级" ) ;
232+ assert ! ( err. contains( "内容过长" ) , "报错应说明超限原因: {err}" ) ;
233+ }
234+
235+ /// AAA(P2-6):边界——合计恰为 DIFF_MD_CHARS_MAX 时不误伤(正常路径
236+ /// 与原行为一致:与既有 3 例同内核,仅多一次字符计数)。
237+ #[ test]
238+ fn diff_markdown_ops_at_char_limit_still_diffs ( ) {
239+ // Arrange:old 恰占满上限(单行——行数乘积 1×0 不触发 LCS 回退)
240+ let old_md = "甲" . repeat ( DIFF_MD_CHARS_MAX ) ;
241+ let new_md = String :: new ( ) ;
242+ // Act
243+ let out = diff_markdown_ops_inner ( & old_md, & new_md) . expect ( "恰好上限不应降级" ) ;
244+ // Assert:整行删除(行为与原实现逐字节一致)
245+ assert_eq ! ( out. ops. len( ) , 1 ) ;
246+ assert ! ( matches!( out. ops[ 0 ] , DiffOp :: Removed ( _) ) ) ;
247+ assert_eq ! ( ( out. added, out. removed) , ( 0 , 1 ) ) ;
248+ }
182249}
0 commit comments