char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
ece94e9
main
1import { highlight } from "@berry/api/web.ts" ; 2import { 3type Line , 4linesOf , 5type Pair , 6pairs , 7pieces , 8type Range , 9trailing , 10wordChanges , 11} from "./model.ts" ; 12import type { FileDiff } from "./types.ts" ; 13import { viewport } from "./viewport.ts" ; 14 15export type Layout = "unified" | "split" ; 16/** Each side's highlighted lines, once the worker has answered. */ 17type Lit = { old :Node [][] | null ; new :Node [][] | null }; 18const KIND = { " " :"ctx" , "-" :"del" , "+" :"add" } as const ; 19const CHUNK_ROWS = 64 ; 20type Row = { pair :Pair ; line :Line | null }; 21/** Wrap the text `root` holds within `ranges` in `<mark>`s, through whatever highlighting it carries. */ 22function wrap ( root :Element , ranges :readonly Range [], className :string ) { 23if ( ranges . length === 0 ) return ; 24const texts :Text [] = []; 25const visit = ( node :Node ) => { 26if ( node . nodeType === 3 ) texts . push ( node as Text ); 27else for ( const child of [ ...node . childNodes ]) visit ( child ); 28}; 29visit ( root ); 30let offset = 0 ; 31for ( const text of texts ) { 32const start = offset ; 33offset += text . data . length ; 34// from the last range back, so `text` stays the part before those already wrapped 35for ( const [ from , to ] of [ ...ranges ]. reverse ()) { 36const a = Math . max ( from , start ) - start ; 37const b = Math . min ( to , offset ) - start ; 38if ( a >= b ) continue ; 39if ( b < text . data . length ) text . splitText ( b ); 40const inside = a > 0 ?text . splitText ( a ) :text ; 41const mark = document . createElement ( "mark" ); 42mark . className = className ; 43inside . replaceWith ( mark ); 44mark . append ( inside ); 45if ( inside === text ) break ; 46} 47} 48} 49 50/** A line's text, highlighted once that is known, with what changed in it marked. */ 51function lineText ( 52line :Line , 53side :"old" | "new" , 54lit :Lit | null , 55words :readonly Range [], 56edit ?:( line :number ) => void , 57) { 58const cell = document . createElement ( "td" ); 59if ( edit && side === "new" && line . new !== null ) { 60cell . addEventListener ( "dblclick" , () => edit ( line . new ! )); 61} 62cell . className = `text ${ KIND [ line . mark ]} ` ; 63const index = line . at [ side ]; 64const highlighted = index === null ?undefined :lit ?.[ side ]?.[ index ]; 65const text = line . text . replace ( / \r$ / , "" ); 66if ( highlighted ) cell . append ( ...highlighted . map (( node ) => node . cloneNode ( true ))); 67else cell . append ( text ); 68wrap ( cell , words , "word" ); 69if ( line . mark !== " " ) { 70const space = trailing ( text ); 71if ( space ) wrap ( cell , [ space ], "space" ); 72} 73if ( line . text . endsWith ( "\r" )) { 74cell . append (< span class = "badge" title = "carriage return" > ␍ < / span>); 75} 76if ( ! line . newline ) { 77cell . append (< span class = "badge" title = "no newline at end of file" > ⊘ < / span>); 78} 79return cell ; 80} 81 82const number= ( n :number | null ) => < td class = "ln" >{ n === null ?"" :String ( n )}< / td>; 83 84/** Searchable chunks, with expensive rows and syntax nodes kept only around the viewport. */ 85export function rendering ( root :HTMLElement ) { 86const window = viewport ( root ); 87const waiting = new Set <() => Promise < void >>(); 88let lighting = false ; 89let disposed = false ; 90 91async function light () { 92if ( lighting || disposed ) return ; 93lighting = true ; 94try { 95for ( const next of waiting ) { 96waiting . delete ( next ); 97if ( disposed ) break ; 98await next (); 99} 100} finally { 101lighting = false ; 102} 103} 104 105return { 106file ( 107file :FileDiff , 108layout :Layout , 109unfolded :Set < number >, 110unfold :( at :number ) => void , 111edit ?:( line :number ) => void , 112) { 113const element = < div class = "diff-content" / > as HTMLElement ; 114const chunks :ReturnType < typeof window . add >[] = []; 115const words = new Map < Pair , ReturnType < typeof wordChanges >>(); 116let lit :Lit | null = null ; 117let mounted = 0 ; 118let visible = 0 ; 119let attempted = false ; 120let pending = false ; 121let removed = false ; 122let count = 0 ; 123const illuminate = async () => { 124if ( removed || ! visible || ! mounted || attempted || pending ) return ; 125attempted = true ; 126pending = true ; 127const { sides} = linesOf ( file ); 128const [ old , neu ] = await Promise . all ( 129[ sides . old , sides . new ]. map (( lines ) => 130lines . length ?highlight ( lines . join ( "\n" ), { path :file . path }). catch (() => null ) :null 131), 132); 133pending = false ; 134if ( disposed || removed ) return ; 135if ( ! mounted ) { 136attempted = false ; 137return ; 138} 139attempted = true ; 140if ( old === null && neu === null ) return ; 141lit = { old, new :neu }; 142for ( const chunk of chunks ) chunk . redraw (); 143}; 144const queue = () => { 145if ( ! visible || ! mounted || attempted || pending || removed ) return ; 146waiting . add ( illuminate ); 147void light (); 148}; 149 150for ( const [ at , piece ] of pieces ( file ). entries ()) { 151if ( piece . kind === "gap" || ( piece . kind === "fold" && ! unfolded . has ( at ))) { 152element . append ( 153< div class = "diff-fold" > 154{ piece . kind === "gap" 155 ?`⋯ ${ piece . count } unchanged lines not loaded` 156 :( 157< button type = "button" class = "link fold" _onclick = {() => unfold ( at )}> 158⋯ { String ( piece . lines . length )} unchanged lines 159< / button> 160)} 161</ div >, 162); 163count ++ ; 164continue ; 165} 166const paired = pairs ( piece . lines ); 167const partners = new Map < Line , Pair >(); 168for ( const pair of paired ) { 169if ( pair . old ) partners . set ( pair . old , pair ); 170if ( pair . new ) partners . set ( pair . new , pair ); 171} 172const rows :Row [] = layout === "unified " 173 ?piece . lines . map (( line ) => ({ pair :partners . get ( line ) ! , line}) ) 174: paired . map (( pair ) => ({ pair , line : null })); 175count += rows . length ; 176for ( let start = 0 ; start < rows . length ; start += CHUNK_ROWS ) { 177const part = rows . slice ( start , start + CHUNK_ROWS ); 178const chunk = < div class = { `diff-chunk ${ layout } ` } /> as HTMLElement; 179chunk . style . setProperty ( "--rows" , String ( part . length )); 180let rich = false ; 181let seen = false ; 182const draw = ( full : boolean ) => { 183if ( rich !== full ) mounted += full ? 1 : - 1 ; 184rich = full ; 185if ( ! full ) { 186if ( ! mounted ) { 187lit = null ; 188attempted = false ; 189words . clear (); 190} 191const text = ( line : Line | null ) => line ?. text . replace ( / \r$ / , "" ) ?? "" ; 192chunk . replaceChildren ( 193layout === "unified" 194? ( 195< pre class = "diff-plain" >{ part . map (({ line }) => text ( line )). join ( "\n" ) + "\n" }</ pre > 196) 197: ( 198< div class = "diff-plain-split" > 199{([ "old" , "new" ] as const ). map (( side ) => ( 200< pre class = "diff-plain" >{ part . map (({ pair }) => text ( pair [ side ])). join ( "\n" ) + "\n" }</ pre > 201))} 202</ div > 203), 204); 205return ; 206} 207const table = ( 208< table class = { `diff ${ layout } ` }> 209< colgroup > 210{ layout === "unified" 211? [< col class = "gutter" / >, <col class="gutter" / >, < col />] 212: [< col class = "gutter" / >, <col / >, < col class = "gutter" / >, <col / >]} 213</ colgroup > 214{ part . map (({ pair , line }) => { 215let changes = words . get ( pair ); 216if ( changes === undefined ) { 217changes = pair . old && pair . new && pair . old !== pair . new 218? wordChanges ( pair . old . text , pair . new . text ) 219: null ; 220words . set ( pair , changes ); 221} 222if ( line ) { 223const side = line . mark === "-" ? "old" : "new" ; 224return ( 225< tr class = { KIND [ line . mark ]}> 226{ number ( line . old )} 227{ number ( line . new )} 228{ lineText ( line , side , lit , changes ?.[ side ] ?? [], edit )} 229< / tr> 230); 231} 232const side = ( line : Line | null , which : "old" | "new" ) => 233line 234? [ 235number ( line [ which ]), 236lineText ( line , which , lit , changes ?.[ which ] ?? [], edit ), 237] 238: [ number ( null ), < td class = "text none" / >]; 239return < tr >{ side ( pair . old , "old" )}{ side ( pair . new , "new" )}< / tr >; 240})} 241</ table > 242); 243chunk . replaceChildren ( table ); 244queue (); 245}; 246chunks . push ( window . add ( chunk , draw , ( intersects ) => { 247if ( seen === intersects ) return ; 248seen = intersects ; 249visible += seen ? 1 : - 1 ; 250queue (); 251})); 252element . append ( chunk ); 253} 254} 255return { 256element , 257rows : count , 258dispose () { 259removed = true ; 260waiting . delete ( illuminate ); 261for ( const chunk of chunks ) chunk . dispose (); 262lit = null ; 263words . clear (); 264}, 265}; 266}, 267dispose () { 268disposed = true ; 269waiting . clear (); 270window . dispose (); 271}, 272}; 273}