@@ -11,7 +11,7 @@ use tauri::State;
1111use crate :: commands:: AppState ;
1212use crate :: db_ai_usage:: AiUsageRecord ;
1313use crate :: db_notes_versions:: NoteVersion ;
14- use crate :: note_diff:: { diff_markdown, diff_sections, DiffOp , SectionDiff } ;
14+ use crate :: note_diff:: { diff_markdown, diff_sections, diff_stats , DiffOp , SectionDiff } ;
1515use crate :: types:: Note ;
1616
1717/// 版本列表(旧→新;含惰性首快照——旧数据迁移兼容)。
@@ -80,6 +80,31 @@ pub fn diff_markdown_sections(old_md: String, new_md: String) -> Vec<SectionDiff
8080 diff_sections ( & old_md, & new_md)
8181}
8282
83+ /// 整篇有序行级 diff 响应(diff_markdown_ops 数据源——三态流 + 行数统计)。
84+ #[ derive( Debug , Clone , serde:: Serialize ) ]
85+ #[ serde( rename_all = "camelCase" ) ]
86+ pub struct MarkdownDiffOps {
87+ /// 有序三态流(base/refined 各行全集划分——LCS 保序,前端按栏消费染色)
88+ pub ops : Vec < DiffOp > ,
89+ pub added : usize ,
90+ pub removed : usize ,
91+ }
92+
93+ /// 任意两篇 markdown 的整篇有序行级 diff(精修工作台行级标色/差异模式)。
94+ ///
95+ /// @ai-context Why(批 3 / 用户问题11):工作台此前只有章节分组
96+ /// diff_markdown_sections(丢失行间顺序,无法行级染色);
97+ /// note_versions_diff 需 DB 版本 id,只读/内存文本对比不可用——
98+ /// 补薄命令直接暴露 note_diff::diff_markdown 全文档流(纯函数
99+ /// 复用,无第二套 diff 引擎);前端三入口(会话级/笔记级/只读)
100+ /// 统一经本命令对"实际展示的两版文本"取数,保证行与渲染对齐。
101+ #[ tauri:: command]
102+ pub fn diff_markdown_ops ( old_md : String , new_md : String ) -> MarkdownDiffOps {
103+ let ops = diff_markdown ( & old_md, & new_md) ;
104+ let ( added, removed, _) = diff_stats ( & ops) ;
105+ MarkdownDiffOps { ops, added, removed }
106+ }
107+
83108/// 读版本并校验归属(diff 输入防御)。
84109fn get_version_for_note ( state : & AppState , note_id : i64 , version_id : i64 ) -> Result < NoteVersion , String > {
85110 let v = state
@@ -92,3 +117,66 @@ fn get_version_for_note(state: &AppState, note_id: i64, version_id: i64) -> Resu
92117 }
93118 Ok ( v)
94119}
120+
121+ #[ cfg( test) ]
122+ mod tests {
123+ use super :: * ;
124+
125+ /// AAA:整篇有序流——前后未变行保序、中段删除/新增按原始出现序交错
126+ /// (工作台染色按流消费即得各栏行序,无需二次对齐)。
127+ #[ test]
128+ fn diff_markdown_ops_keeps_whole_document_order ( ) {
129+ // Arrange:首行未变、中段一删一增、尾行未变
130+ let old_md = "甲\n 删我\n 尾" . to_string ( ) ;
131+ let new_md = "甲\n 加我\n 尾" . to_string ( ) ;
132+ // Act
133+ let out = diff_markdown_ops ( old_md, new_md) ;
134+ // Assert:三态各自一个且有序(unchanged → removed → added)
135+ assert_eq ! (
136+ out. ops,
137+ vec![
138+ DiffOp :: Unchanged ( "甲" . into( ) ) ,
139+ DiffOp :: Removed ( "删我" . into( ) ) ,
140+ DiffOp :: Added ( "加我" . into( ) ) ,
141+ DiffOp :: Unchanged ( "尾" . into( ) ) ,
142+ ]
143+ ) ;
144+ assert_eq ! ( ( out. added, out. removed) , ( 1 , 1 ) ) ;
145+ }
146+
147+ /// AAA:空侧边界——单侧全量标记,另一侧无行。
148+ #[ test]
149+ 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:空基线的整篇都是新增
153+ assert_eq ! ( out. ops. len( ) , 2 ) ;
154+ assert ! ( out. ops. iter( ) . all( |o| matches!( o, DiffOp :: Added ( _) ) ) ) ;
155+ assert_eq ! ( ( out. added, out. removed) , ( 2 , 0 ) ) ;
156+
157+ let out = diff_markdown_ops ( "删1\n 删2" . to_string ( ) , String :: new ( ) ) ;
158+ // Assert:空精修版的整篇都是删除
159+ assert ! ( out. ops. iter( ) . all( |o| matches!( o, DiffOp :: Removed ( _) ) ) ) ;
160+ assert_eq ! ( ( out. added, out. removed) , ( 0 , 2 ) ) ;
161+ }
162+
163+ /// AAA:serde 契约——顶层 camelCase + DiffOp 外部小写标签
164+ /// (与前端 types/notes.ts DiffOp 联合类型逐字对齐)。
165+ #[ test]
166+ fn diff_markdown_ops_serializes_camel_case_and_lowercase_tags ( ) {
167+ // Arrange/Act
168+ let out = diff_markdown_ops ( "旧\n 同" . to_string ( ) , "新\n 同" . to_string ( ) ) ;
169+ let json = serde_json:: to_value ( & out) . unwrap ( ) ;
170+ // Assert:顶层键 camelCase(ops/added/removed)
171+ assert ! ( json. get( "ops" ) . is_some( ) ) ;
172+ assert ! ( json. get( "added" ) . is_some( ) ) ;
173+ assert ! ( json. get( "removed" ) . is_some( ) ) ;
174+ assert ! ( json. get( "ops" ) . unwrap( ) . is_array( ) ) ;
175+ // Assert:op 条目为外部小写标签 {removed: "旧"} / {added: "新"} / {unchanged: "同"}
176+ assert_eq ! ( json[ "ops" ] [ 0 ] [ "removed" ] , "旧" ) ;
177+ assert_eq ! ( json[ "ops" ] [ 1 ] [ "added" ] , "新" ) ;
178+ assert_eq ! ( json[ "ops" ] [ 2 ] [ "unchanged" ] , "同" ) ;
179+ assert_eq ! ( json[ "added" ] , 1 ) ;
180+ assert_eq ! ( json[ "removed" ] , 1 ) ;
181+ }
182+ }
0 commit comments