@@ -3,59 +3,67 @@ import type { DiffViewProps } from './types.js';
// 一条差异:same 原样、add 只在右边、del 只在左边
type DiffOp = { type : 'same' | 'add' | 'del' ; text : string } ;
// 比较用的一行:text 是显示原文,key 是比对键(忽略空白模式下把行内空白压成一个空格再 trim)
type Row = { text : string ; key : string } ;
// LCS 计算上限:中间段行数乘积超过这个值就退化为「整段替换」,避免大文件把主线程卡住
const DP_LIMIT = 400 _000 ;
/*** 比对键归一:行内连续空白压成一个空格并去掉首尾空白;显示仍用原文,缩进不丢 ***/
function normalize ( line : string ) : string {
return line . replace ( /\s+/g , ' ' ) . trim ( ) ;
}
/*** 切行:末尾换行不产生一行空内容(否则每个文件都会多出一条假的空行差异) ***/
function toLines ( text : string ) : string [ ] {
function toRows ( text : string , ignore : boolean ) : Row [ ] {
const lines = text . split ( /\r?\n/ ) ;
if ( lines . length > 1 && lines [ lines . length - 1 ] === '' ) lines . pop ( ) ; // 尾部换行忽略
return lines ;
return lines . map ( ( line ) = > ( { text : line , key : ignore ? normalize ( line ) : line } ) ) ;
}
/*** 中间段做 LCS 对齐:先剪掉公共前后缀,剩下的才真算,日常改动只占几行 ***/
function alignMiddle ( a : string [ ] , b : string [ ] ) : DiffOp [ ] {
if ( ! a . length || ! b . length ) return [ . . . a . map ( ( text ) = > ( { type : 'del' as const , text } ) ) , . . . b . map ( ( text ) = > ( { type : 'add' as const , text } ) ) ] ;
if ( a . length * b . length > DP_LIMIT ) return [ . . . a . map ( ( text ) = > ( { type : 'del' as const , text } ) ) , . . . b . map ( ( text ) = > ( { type : 'add' as const , text } ) ) ] ; // 超限:整段替换,语义仍正确
function alignMiddle ( a : Row [ ] , b : Row [ ] ) : DiffOp [ ] {
if ( ! a . length || ! b . length ) return [ . . . a . map ( ( r ) = > ( { type : 'del' as const , text : r.text } ) ) , . . . b . map ( ( r ) = > ( { type : 'add' as const , text : r.text } ) ) ] ;
if ( a . length * b . length > DP_LIMIT ) return [ . . . a . map ( ( r ) = > ( { type : 'del' as const , text : r.text } ) ) , . . . b . map ( ( r ) = > ( { type : 'add' as const , text : r.text } ) ) ] ; // 超限:整段替换,语义仍正确
const dp : number [ ] [ ] = Array . from ( { length : a.length + 1 } , ( ) = > new Array < number > ( b . length + 1 ) . fill ( 0 ) ) ; // dp[i][j]: a 前 i 行与 b 前 j 行的 LCS 长度
for ( let i = a . length - 1 ; i >= 0 ; i -= 1 ) {
for ( let j = b . length - 1 ; j >= 0 ; j -= 1 ) {
dp [ i ] [ j ] = a [ i ] === b [ j ] ? dp [ i + 1 ] [ j + 1 ] + 1 : Math.max ( dp [ i + 1 ] [ j ] , dp [ i ] [ j + 1 ] ) ;
dp [ i ] [ j ] = a [ i ] . key === b [ j ] . key ? dp [ i + 1 ] [ j + 1 ] + 1 : Math.max ( dp [ i + 1 ] [ j ] , dp [ i ] [ j + 1 ] ) ;
}
}
const ops : DiffOp [ ] = [ ] ;
let i = 0 ;
let j = 0 ;
while ( i < a . length && j < b . length ) {
if ( a [ i ] === b [ j ] ) { ops . push ( { type : 'same' , text : a [ i ] } ) ; i += 1 ; j += 1 ; continue ; }
if ( dp [ i + 1 ] [ j ] >= dp [ i ] [ j + 1 ] ) { ops . push ( { type : 'del' , text : a [ i ] } ) ; i += 1 ; continue ; }
ops . push ( { type : 'add' , text : b [ j ] } ) ; j += 1 ;
if ( a [ i ] . key === b [ j ] . key ) { ops . push ( { type : 'same' , text : a [ i ] . text } ) ; i += 1 ; j += 1 ; continue ; }
if ( dp [ i + 1 ] [ j ] >= dp [ i ] [ j + 1 ] ) { ops . push ( { type : 'del' , text : a [ i ] . text } ) ; i += 1 ; continue ; }
ops . push ( { type : 'add' , text : b [ j ] . text } ) ; j += 1 ;
}
for ( ; i < a . length ; i += 1 ) ops . push ( { type : 'del' , text : a [ i ] } ) ;
for ( ; j < b . length ; j += 1 ) ops . push ( { type : 'add' , text : b [ j ] } ) ;
for ( ; i < a . length ; i += 1 ) ops . push ( { type : 'del' , text : a [ i ] . text } ) ;
for ( ; j < b . length ; j += 1 ) ops . push ( { type : 'add' , text : b [ j ] . text } ) ;
return ops ;
}
/*** 逐行差异:剪公共前后缀后对齐中间段,输出完整 same / add / del 序列 ***/
function diffLines ( before : string , after : string ) : DiffOp [ ] {
const a = toLines ( before ) ;
const b = toLines ( after ) ;
function diffLines ( before : string , after : string , ignore : boolean ) : DiffOp [ ] {
const a = toRows ( before , ignore ) ;
const b = toRows ( after , ignore ) ;
let head = 0 ;
while ( head < a . length && head < b . length && a [ head ] === b [ head ] ) head += 1 ; // 公共前缀
while ( head < a . length && head < b . length && a [ head ] . key === b [ head ] . key ) head += 1 ; // 公共前缀
let tail = 0 ;
while ( tail < a . length - head && tail < b . length - head && a [ a . length - 1 - tail ] === b [ b . length - 1 - tail ] ) tail += 1 ; // 公共后缀
while ( tail < a . length - head && tail < b . length - head && a [ a . length - 1 - tail ] . key === b [ b . length - 1 - tail ] . key ) tail += 1 ; // 公共后缀
const midA = a . slice ( head , a . length - tail ) ;
const midB = b . slice ( head , b . length - tail ) ;
return [
. . . a . slice ( 0 , head ) . map ( ( text ) = > ( { type : 'same' as const , text } ) ) ,
. . . a . slice ( 0 , head ) . map ( ( r ) = > ( { type : 'same' as const , text : r.text } ) ) ,
. . . alignMiddle ( midA , midB ) ,
. . . a . slice ( a . length - tail ) . map ( ( text ) = > ( { type : 'same' as const , text } ) ) ,
. . . a . slice ( a . length - tail ) . map ( ( r ) = > ( { type : 'same' as const , text : r.text } ) ) ,
] ;
}
/*** 文本差异对比:行级对齐 + 增删着色 + 双行号列,超限时退化为整段替换(只影响精度不影响语义) ***/
export function diffView ( props : DiffViewProps ) : HTMLElement {
const ops = diffLines ( props . before , props . after ) ;
const ignore = props . ignoreWhitespace === true ; // 只在显式开启时归一行内空白
const ops = diffLines ( props . before , props . after , ignore ) ;
const added = ops . filter ( ( o ) = > o . type === 'add' ) . length ; // 新增行数:统计条与读屏共用
const removed = ops . filter ( ( o ) = > o . type === 'del' ) . length ; // 删除行数
let oldNo = 0 ;
@@ -75,7 +83,13 @@ export function diffView(props: DiffViewProps): HTMLElement {
} ) ;
const stats = props . showStats === false
? null
: h ( 'div' , { class : 'ua-diff-stats' } , h ( 'span' , { class : 'ua-diff-add' } , ` + ${ added } ` ) , h ( 'span' , { class : 'ua-diff-del' } , ` − ${ removed } ` ) ) ;
: h (
'div' ,
{ class : 'ua-diff-stats' } ,
h ( 'span' , { class : 'ua-diff-add' } , ` + ${ added } ` ) ,
h ( 'span' , { class : 'ua-diff-del' } , ` − ${ removed } ` ) ,
ignore ? h ( 'span' , { class : 'ua-diff-note' } , '已忽略行内空白差异' ) : null , // 归一口径必须写在脸上,否则「为什么不报这几行」说不清
) ;
return h (
'div' ,
{ class : 'ua-diff' , role : 'group' , 'aria-label' : props . ariaLabel ? ? ` 文本差异:新增 ${ added } 行,删除 ${ removed } 行 ` } ,