char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
80904d5
main
1import { type View , type ViewContext } from "@berry/api/web.ts" ; 2import { editing } from "./edit-view.tsx" ; 3import { editable } from "./eligibility.ts" ; 4import { fileKey , lineCounts } from "./model.ts" ; 5import { type Layout , rendering } from "./render.tsx" ; 6import type { FileDiff , Id , Reply , Revision , Shown } from "./types.ts" ; 7 8const LAYOUT = "berry.diff.layout" ; 9const VIEWED = "berry.diff.viewed" ; 10/** How many files' viewed marks are remembered, the oldest forgotten first. */ 11const REMEMBERED = 5000 ; 12const PAGE = 100 ; 13 14// lockfiles and the like: shown folded, since nobody reads them line by line 15const GENERATED = / (?:^|\/)(?:[\w.-]+\.lock|package-lock\.json|pnpm-lock\.yaml|go\.sum)$ / ; 16 17// thousands of marks, which every file reads as it draws: parsed again only once another tab has changed them 18let parsed :{ raw :string | null ; marks :Record < string , number > } | null = null ; 19 20function viewedMarks () :Record < string , number > { 21const raw = localStorage . getItem ( VIEWED ); 22if ( parsed ?. raw !== raw ) { 23let marks = {}; 24try { 25marks = JSON . parse ( raw ?? "{}" ); 26} catch { 27// a mangled store forgets its marks 28} 29parsed = { raw, marks}; 30} 31return parsed ! . marks ; 32} 33 34function markViewed ( key :string , viewed :boolean ) { 35const marks = { ...viewedMarks () }; 36if ( viewed ) marks [ key ] = Date . now (); 37else delete marks [ key ]; 38const kept = Object . entries ( marks ). sort (([, a ], [, b ]) => b - a ). slice ( 0 , REMEMBERED ); 39localStorage . setItem ( VIEWED , JSON . stringify ( Object . fromEntries ( kept ))); 40} 41 42const relative = new Intl . RelativeTimeFormat ( undefined , { numeric :"auto" }); 43const UNITS :[ Intl . RelativeTimeFormatUnit , number ][] = [ 44[ "year" , 31536000 ], 45[ "month" , 2592000 ], 46[ "week" , 604800 ], 47[ "day" , 86400 ], 48[ "hour" , 3600 ], 49[ "minute" , 60 ], 50]; 51function ago ( timestamp :string ) { 52const seconds = ( new Date ( timestamp ). getTime () - Date . now ()) / 1000 ; 53for ( const [ unit , size ] of UNITS ) { 54if ( Math . abs ( seconds ) >= size ) return relative . format ( Math . trunc ( seconds / size ), unit ); 55} 56return relative . format ( 0 , "second" ); 57} 58 59const idOf = ( id :Id , className :string , full = false ) => ( 60< span class = { className }> 61{ id . prefix ?< b >{ id . prefix }</ b > :[]} 62{ full ?id . full . slice ( id . prefix . length ) :id . rest } 63</ span > 64); 65 66function ids ( revision :Revision ) { 67if ( revision . commit === null ) return [< span class = "change" > working tree < / span>]; 68return [ 69 ...( revision . change ?[ idOf ( revision . change , "change" )] :[]), 70idOf ( revision . commit , "commit" ), 71]; 72} 73 74const glyph = ( revision :Revision ) => 75revision . workingCopy ?"@" :revision . conflict ?"×" :revision . immutable ?"◆" :"○" ; 76 77const summaryOf = ( revision :Revision ) => 78revision . description . split ( "\n" )[ 0 ] || 79( revision . commit === null ?"uncommitted changes" :"(no description set)" ); 80 81type FileView = { 82file :FileDiff ; 83key :string ; 84section :HTMLElement ; 85/** Which folds are open, by their place among the file's pieces. */ 86unfolded :Set < number >; 87draw () :void ; 88dispose () :void ; 89}; 90 91/** A revision's changes beside the history it is in: the working copy at first. */ 92export function diffView () :View { 93const log = < nav class = "diff-log" aria - label = "history" / > as HTMLElement ; 94const head = < header class = "diff-head" / > as HTMLElement ; 95const list = < div class = "diff-files" / > as HTMLElement ; 96const main = < section class = "diff-main" >{ head }{ list }< / section > as HTMLElement ; 97const element = < div class = "diff-view" >{ log }{ main }< / div > as HTMLElement ; 98 99let context :ViewContext | null = null ; 100let shown :Shown | null = null ; 101let error :string | null = null ; 102let limit = PAGE ; 103let layout :Layout = localStorage . getItem ( LAYOUT ) === "split" ?"split" :"unified" ; 104/** The request on its way, and the place it is for. */ 105let fetching :{ route :string ; controller :AbortController } | null = null ; 106let again = false ; 107let disposed = false ; 108/** The description being edited in place of the head's subject and body, and which it was. */ 109let rewording :{ change :string ; before :string ; editor :HTMLElement } | null = null ; 110/** Whether each file is open, by path, as the user left it in this revision. */ 111const open = new Map < string , boolean >(); 112let files = new Map < string , FileView >(); 113const renderer = rendering ( main ); 114const edits = editing ( 115( path ) => { 116const view = [ ...files . values ()]. find (( view ) => view . file . path === path ); 117if ( view ) { 118open . set ( path , true ); 119view . draw (); 120} 121}, 122( path ) => { 123if ( ! edits . has ( path )) { 124files = new Map ([ ...files ]. filter (([, view ]) => { 125if ( view . file . path !== path ) return true ; 126view . dispose (); 127return false ; 128})); 129} 130void refresh (); 131}, 132( message ) => { 133error = message ; 134drawHead (); 135}, 136); 137 138function fileView ( file :FileDiff , key :string ) :FileView { 139const viewedKey = ` ${ shown ! . root } \0 ${ key } ` ; 140const viewed = viewedKey in viewedMarks (); 141const { additions, removals} = lineCounts ( file ); 142const toggle = < button type = "button" class = "chevron" / > as HTMLButtonElement ; 143const box = < input type = "checkbox" /> as HTMLInputElement ; 144box . checked = viewed ; 145const body = < div class = "file-body" / > as HTMLElement ; 146const { revision} = shown ! ; 147// jj rewrites a commit's descendants onto an edit to it, so any it would let change can be edited 148const canEdit = ( revision . workingCopy || ( shown ! . vcs === "jj" && ! revision . immutable )) && 149editable ( file ); 150const begin = ( line ?:number ) => { 151if ( context ?. route === shown ! . revision . route ) void edits . begin ( view . file , shown ! . root , line ); 152}; 153const editLink = ( 154< button type = "button" class = "link" _onclick = {() => begin ()}> 155edit 156</ button > 157) as HTMLButtonElement ; 158const modes = file . modes . old !== null && file . modes . new !== null && 159file . modes . old !== file . modes . new 160 ?[< span class = "mode" >{ file . modes . old } → { file . modes . new }</ span >] 161 :[]; 162const section = ( 163< section class = "file-diff" > 164< header class = "file-summary" > 165{ toggle} 166< span class = "file-summary-content" > 167< span class = "path" >{ file . from ?` ${ file . from } → ${ file . path } ` :file . path }</ span > 168< span class = "file-actions" > 169< span class = "add-count" > + { String ( additions )}< / span> 170< span class = "del-count" > - { String ( removals )}< /span> 171{ file . status === "modified" 172? [] 173: < span class = { `status ${ file . status } ` }>{ file . status }</ span >} 174{ modes } 175{ GENERATED . test ( file . path ) ? < span class = "generated-note" > generated < /span> : []} 176{ canEdit ? editLink : []} 177< label class = "viewed" >{ box } viewed < /label> 178</ span > 179< /span> 180</ header > 181{ body } 182< /section> 183) as HTMLElement ; 184let drawn : { key : string ; layout : Layout ; content : ReturnType < typeof renderer . file > } | null = 185null ; 186const reset = () => { 187drawn ?. content . dispose (); 188drawn = null ; 189}; 190const view : FileView = { 191file , 192key , 193section , 194unfolded : new Set (), 195draw , 196dispose : reset , 197}; 198const isOpen = () => 199open . get ( file . path ) ?? ( ! box . checked && ! GENERATED . test ( file . path ) && ! file . binary ); 200 201function draw () { 202const file = view . file ; 203box . checked = ` ${ shown ! . root } \0 ${ view . key } ` in viewedMarks (); 204const { additions , removals } = lineCounts ( file ); 205section . querySelector ( ".add-count" ) ! . textContent = `+ ${ additions } ` ; 206section . querySelector ( ".del-count" ) ! . textContent = `- ${ removals } ` ; 207const opened = isOpen (); 208 209toggle . setAttribute ( "aria-expanded" , String ( opened )); 210toggle . setAttribute ( "aria-label" , ` ${ opened ? "fold" : "unfold" } ${ file . path } ` ); 211body . hidden = ! opened ; 212editLink . hidden = edits . has ( file . path , shown ! . revision . route ); 213if ( edits . attach ( body , file , layout , shown ! . revision . route )) { 214reset (); 215return ; 216} 217if ( opened && drawn ?. key === view . key && drawn . layout === layout ) return ; 218reset (); 219section . style . setProperty ( "--rows" , "0" ); 220if ( ! opened ) return body . replaceChildren (); 221if ( file . binary ) return body . replaceChildren (< p class = "meta file-note" > binary file < /p>); 222if ( file . hunks . length === 0 ) { 223return body . replaceChildren ( 224< p class = "meta file-note" > 225{ file . status === "added" ? "empty file" : "no changes to its content" } 226</ p >, 227); 228} 229const content = renderer . file ( file , layout , view . unfolded , ( at ) => { 230view . unfolded . add ( at ); 231reset (); 232draw (); 233}, canEdit ? begin : undefined ); 234drawn = { key : view . key , layout , content }; 235body . replaceChildren ( content . element ); 236section . style . setProperty ( "--rows" , String ( content . rows )); 237} 238toggle . addEventListener ( "click" , () => { 239open . set ( file . path , ! isOpen ()); 240draw (); 241}); 242box . addEventListener ( "change" , () => { 243markViewed ( ` ${ shown ! . root } \0 ${ view . key } ` , box . checked ); 244open . set ( file . path , ! box . checked ); 245draw (); 246if ( box . checked && section . getBoundingClientRect (). top < main . getBoundingClientRect (). top ) { 247section . scrollIntoView ({ block : "start" }); 248} 249}); 250draw (); 251return view ; 252} 253 254function reword ( revision : Revision ) { 255if ( ! context || ! shown || rewording ) return ; 256const request = context . request ; 257const root = shown . root ; 258const change = revision . change ! . full ; 259const before = revision . description ; 260const input = ( 261< textarea 262aria-label = "commit description" 263rows = { Math . max ( 4 , before . split ( "\n" ). length + 1 )} 264/> 265) as HTMLTextAreaElement ; 266input . value = before ; 267const message = < p class = "diff-error" role = "alert" / > as HTMLElement ; 268const save = < button type = "button" > save </ button > as HTMLButtonElement ; 269const cancel = < button type = "button" > cancel </ button > as HTMLButtonElement ; 270const editor = ( 271< div class = "commit-reword" > 272{ input } 273{ message } 274< div class = "commit-reword-actions" > 275{ save } 276{ cancel } 277< span class = "meta" > ctrl + enter saves · esc cancels < /span> 278</ div > 279< / div> 280) as HTMLElement ; 281const active = { change , before , editor }; 282let pending = false ; 283const close = () => { 284if ( rewording !== active ) return ; 285const focused = editor . contains ( document . activeElement ); 286rewording = null ; 287drawHead (); 288if ( focused ) head . querySelector < HTMLElement >( ".commit-subject" )?. focus (); 289}; 290const submit = async () => { 291if ( pending ) return ; 292pending = true ; 293save . disabled = cancel . disabled = input . disabled = true ; 294message . textContent = "" ; 295try { 296await request ( "reword" , { root , change , before , description : input . value }); 297close (); 298void refresh (); 299} catch ( error ) { 300message . textContent = error instanceof Error ? error . message : String ( error ); 301} finally { 302pending = false ; 303save . disabled = cancel . disabled = input . disabled = false ; 304} 305}; 306save . addEventListener ( "click" , submit ); 307cancel . addEventListener ( "click" , close ); 308input . addEventListener ( "keydown" , ( event ) => { 309if ( event . key === "Escape" && ! pending ) { 310event . preventDefault (); 311close (); 312} else if ( event . key === "Enter" && ( event . ctrlKey || event . metaKey )) { 313event . preventDefault (); 314void submit (); 315} 316}); 317rewording = active ; 318drawHead (); 319input . focus (); 320} 321 322/** Each revision's entry in the log, kept while what it shows stays the same, as it mostly does across refreshes. */ 323let entries = new Map < string , { shows : string ; link : HTMLElement }>(); 324function drawLog () { 325if ( ! shown || ! context ) { 326entries . clear (); 327return log . replaceChildren (); 328} 329const current = shown . revision ; 330const next = new Map < string , { shows : string ; link : HTMLElement }>(); 331const items = shown . log . map (( revision ) => { 332const key = ` ${ revision . route } \0 ${ revision . commit ?. full ?? "" } ` ; 333const when = revision . author ? ago ( revision . author . timestamp ) : "" ; 334const href = context ! . href ( revision . route ); 335const shows = JSON . stringify ([ revision , when , href ]); 336const kept = entries . get ( key ); 337const link = kept ?. shows === shows ? kept . link : ( 338< a href = { href } class = "revision" > 339< span class = "revision-line" > 340< span class = "glyph" >{ glyph ( revision )}< /span> 341{ ids ( revision )} 342{ revision . refs . map (( ref ) => < span class = "ref" >{ ref }< /span>)} 343</ span > 344< span class = { revision . description ? "revision-summary" : "revision-summary none" }> 345{ revision . empty ? < span class = "tag" >( empty )< / span > : []} 346{ summaryOf ( revision )} 347</ span > 348{ revision . author 349? ( 350< span 351class = "revision-meta" 352title = { new Date ( revision . author . timestamp ). toLocaleString ()} 353> 354{ revision . author . name } · { when } 355</ span > 356) 357: []} 358</ a > 359) as HTMLElement ; 360next . set ( key , { shows , link }); 361if ( revision . route === current . route ) link . setAttribute ( "aria-current" , "page" ); 362else link . removeAttribute ( "aria-current" ); 363link . classList . toggle ( "immutable" , revision . immutable ); 364return link ; 365}); 366entries = next ; 367const more = shown . log . length >= limit 368? [ 369( log . querySelector ( ":scope > .more" ) as HTMLElement | null ) ?? ( 370< button 371type = "button" 372class = "link more" 373_onclick = {() => { 374limit += PAGE ; 375void refresh (); 376}} 377> 378older… 379</ button > 380) as HTMLElement , 381] 382: []; 383const nodes = [ ... items , ... more ]; 384// moving nodes that stay would lose the reader's focus and lay the list out again 385if ( nodes . length !== log . children . length || nodes . some (( node , i ) => node !== log . children [ i ])) { 386log . replaceChildren ( ... nodes ); 387} 388} 389 390let heading : { key : string ; editor : HTMLElement | null } | null = null ; 391function drawHead () { 392if ( ! shown ) { 393return head . replaceChildren ( 394error ? < p class = "diff-error" >{ error }< / p> : <p class="meta">loading…< / p >, 395); 396} 397const key = JSON . stringify ([ 398shown . root , 399shown . vcs , 400shown . revision , 401shown . omitted , 402[ ... files . keys ()], 403layout , 404error , 405]); 406const editor = rewording ?. editor ?? null ; 407if ( heading ?. key === key && heading . editor === editor ) return ; 408heading = { key , editor }; 409const { revision , files : changed } = shown ; 410const counts = changed . map ( lineCounts ); 411const authored = { additions : 0 , removals : 0 }; 412const generated = { additions : 0 , removals : 0 }; 413changed . forEach (( file , at ) => { 414const total = GENERATED . test ( file . path ) ? generated : authored ; 415total . additions += counts [ at ]. additions ; 416total . removals += counts [ at ]. removals ; 417}); 418const choice = ( value : Layout ) => ( 419< button 420type = "button" 421aria-pressed = { String ( layout === value )} 422_onclick = {() => { 423if ( layout === value ) return ; 424layout = value ; 425localStorage . setItem ( LAYOUT , value ); 426for ( const view of files . values ()) view . draw (); 427drawHead (); 428}} 429> 430{ value } 431</ button > 432); 433const [ subject , ... rest ] = revision . description . trimEnd (). split ( "\n" ); 434const body = rest . join ( "\n" ). trim (); 435const meta = < table class = "commit-meta" / > as HTMLTableElement ; 436const row = ( key : string , value : Node | string ) => 437meta . append ( 438< tr > 439< th >{ key }</ th > 440< td >{ value }</ td > 441</ tr >, 442); 443if ( revision . author ) { 444row ( "author" , ` ${ revision . author . name } < ${ revision . author . } >` ); 445row ( "date" , new Date ( revision . author . timestamp ). toLocaleString ()); 446} 447if ( revision . change ) row ( "change" , idOf ( revision . change , "change" , true )); 448if ( revision . commit ) row ( "commit" , idOf ( revision . commit , "commit" , true )); 449if ( revision . refs . length ) row ( "refs" , revision . refs . join ( " " )); 450row ( shown . vcs , shown . root ); 451const mutable = shown . vcs === "jj" && ! revision . immutable && revision . change !== undefined ; 452if ( 453rewording && 454! ( mutable && rewording . change === revision . change ! . full && 455rewording . before === revision . description ) 456) rewording = null ; 457const focused = rewording ?. editor . contains ( document . activeElement ) 458? document . activeElement as HTMLElement 459: null ; 460const description = rewording ? [ rewording . editor ] : [ 461< h2 class = { subject ? "commit-subject" : "commit-subject none" }> 462{ revision . empty ? < span class = "tag" >( empty )< / span > : []} 463{ revision . conflict ? < span class = "tag conflict" >( conflict )< / span > : []} 464{ subject || summaryOf ( revision )} 465</ h2 >, 466... ( body ? [< pre class = "commit-body" >{ body }< /pre>] : []), 467] as HTMLElement []; 468if ( mutable && ! rewording ) { 469for ( const target of description ) { 470target . title = "double-click or press F2 to edit the description" ; 471target . tabIndex = 0 ; 472target . addEventListener ( "dblclick" , () => reword ( revision )); 473target . addEventListener ( "keydown" , ( event ) => { 474if ( event . key === "F2" ) reword ( revision ); 475}); 476} 477} 478head . replaceChildren ( 479... description , 480meta , 481< div class = "diff-toolbar" > 482< span class = "diffstat" > 483< span class = "add-count" > 484+ { String ( authored . additions )} 485{ generated . additions 486? ( 487< span class = "generated-count" title = "generated file additions" > 488{ ` (+ ${ generated . additions } )` } 489</ span > 490) 491: []} 492</ span > 493< span class = "del-count" > 494- { String ( authored . removals )} 495{ generated . removals 496? < span title = "generated file removals" >{ ` (- ${ generated . removals } )` }</ span > 497: []} 498</ span > 499< span >{ ` ${ changed . length } changed file ${ changed . length === 1 ? "" : "s" } ` }</ span > 500< / span> 501< span class = "spacer" / > 502< span class = "diff-layout" role = "group" aria - label = "layout" > 503{ choice ( "unified" )} 504{ choice ( "split" )} 505</ span > 506< button type = "button" class = "link" _onclick = {() => void refresh ()}> refresh </ button > 507< /div>, 508... ( error ? [< p class = "diff-error" >{ error }< /p>] : []), 509... ( shown . omitted > 0 510? [< p class = "meta" >{ String ( shown . omitted )} more untracked files not shown </ p >] 511: []), 512... ( changed . length === 0 ? [< p class = "meta" > no changes < / p>] : []), 513< ol class = "diff-index" > 514{ changed . map (( file , at ) => ( 515< li > 516< span class = "add-count" > + { String ( counts [ at ]. additions )}< / span> 517< span class = "del-count" > - { String ( counts [ at ]. removals )}< /span> 518< button 519type = "button" 520class = "link" 521_onclick = {() => { 522const view = [ ... files . values ()]. find (( view ) => view . file . path === file . path ); 523if ( ! view ) return ; 524open . set ( file . path , true ); 525view . draw (); 526view . section . scrollIntoView ({ block : "start" }); 527}} 528> 529< span class = { `status ${ file . status } ` }>{ file . status [ 0 ]. toUpperCase ()}</ span > 530< span >{ file . path }</ span > 531< /button> 532</ li > 533))} 534</ ol >, 535); 536// moving the editor blurs it, but the draft and its selection stay with the element 537focused ?. focus (); 538} 539 540function show ( next : Reply , known : Map < string , FileView >) { 541const sameRevision = shown ?. revision . route === next . revision . route && 542shown . root === next . root ; 543if ( ! sameRevision ) { 544open . clear (); 545main . scrollTop = 0 ; 546} 547// keep what has not changed, and the file at the top of the pane where it is 548const top = main . getBoundingClientRect (). top ; 549const anchor = [ ... files . values ()]. find (( view ) => 550view . section . getBoundingClientRect (). bottom > top 551); 552const offset = anchor ? anchor . section . getBoundingClientRect (). top - top : 0 ; 553const previous = files ; 554// a change left out is one the view has 555const sent = next . files . map (({ key , diff }) => ({ 556key , 557file : diff ?? known . get ( key ) ! . file , 558})); 559for ( const file of edits . files ( next . root , next . revision . route )) { 560if ( ! sent . some (( item ) => item . file . path === file . path )) { 561sent . push ({ key : fileKey ( file ), file }); 562} 563} 564shown = { ... next , files : sent . map (({ file }) => file ) }; 565files = new Map ( 566sent . map (({ key , file }) => { 567const active = sameRevision && edits . has ( file . path , next . revision . route ) 568? [ ... previous . values ()]. find (( view ) => view . file . path === file . path ) 569: undefined ; 570const view = active ?? ( sameRevision ? previous . get ( key ) : undefined ) ?? 571fileView ( file , key ); 572const changed = active !== undefined && active . key !== key ; 573view . file = file ; 574view . key = key ; 575if ( active ) view . draw (); 576if ( changed ) edits . sync ( file . path ); 577return [ key , view ]; 578}), 579); 580for ( const [ key , view ] of previous ) { 581if ( files . get ( key ) !== view ) { 582view . dispose (); 583} 584} 585const nodes = [ ... files . values ()]. map (( view ) => view . section ); 586if ( 587nodes . length !== list . children . length || nodes . some (( node , i ) => node !== list . children [ i ]) 588) { 589const focused = document . activeElement as HTMLElement | null ; 590list . replaceChildren ( ... nodes ); 591if ( focused && list . contains ( focused )) focused . focus (); 592} 593drawHead (); 594drawLog (); 595if ( sameRevision && anchor && files . get ( anchor . key ) === anchor ) { 596main . scrollTop += anchor . section . getBoundingClientRect (). top - top - offset ; 597} 598} 599 600async function refresh () { 601if ( ! context || disposed ) return ; 602const route = context . route ; 603// the same place again waits for the answer on its way; another place makes that answer moot 604if ( fetching ?. route === route ) return void ( again = true ); 605fetching ?. controller . abort (); 606const current = fetching = { route , controller : new AbortController () }; 607again = false ; 608head . classList . add ( "loading" ); 609try { 610const known = new Map ( files ); 611const value = await context . request ( "show" , { route , limit , known : [ ... known . keys ()] }, { 612signal : current . controller . signal , 613}) as unknown as Reply ; 614if ( fetching !== current ) return ; 615error = null ; 616show ( value , known ); 617} catch ( caught ) { 618if ( fetching !== current ) return ; 619error = caught instanceof Error ? caught . message : String ( caught ); 620drawHead (); 621} finally { 622if ( fetching === current ) { 623fetching = null ; 624head . classList . remove ( "loading" ); 625if ( again ) void refresh (); 626} 627} 628} 629 630const onVisible = () => { 631if ( document . visibilityState === "visible" ) void refresh (); 632}; 633document . addEventListener ( "visibilitychange" , onVisible ); 634 635return { 636element , 637update ( next ) { 638const previous = context ; 639context = next ; 640edits . update ( next ); 641if ( previous ?. route !== next . route && rewording ) { 642rewording = null ; 643drawHead (); 644} 645if ( previous ?. route !== next . route || previous . latest !== next . latest ) void refresh (); 646}, 647async beforeLeave ( destination ) { 648element . inert = true ; 649try { 650return await edits . beforeLeave ( destination ); 651} finally { 652element . inert = false ; 653} 654}, 655flush : () => edits . flush (), 656dispose () { 657disposed = true ; 658edits . dispose (); 659fetching ?. controller . abort (); 660for ( const view of files . values ()) view . dispose (); 661renderer . dispose (); 662document . removeEventListener ( "visibilitychange" , onVisible ); 663}, 664}; 665}