char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
baa4ef6
main
1import type { JsonValue } from "@berry/api" ; 2import type { ViewContext , ViewDestination } from "@berry/api/web.ts" ; 3import type { DiffEditor , Layout } from "./editor.ts" ; 4import { 5type Draft , 6draftKey , 7editId , 8loadDrafts , 9recover , 10type Stored , 11storeDrafts , 12} from "./drafts.ts" ; 13import type { OpenFile , Saved , WorkingCopy } from "./edit-types.ts" ; 14import type { FileDiff } from "./types.ts" ; 15 16type Loaded = { 17diffEditor :typeof import ( "./editor.ts" ). diffEditor ; 18merge3 :typeof import ( "./merge.ts" ). merge3 ; 19}; 20 21let loaded :Loaded | null = null ; 22let loading :Promise < Loaded > | null = null ; 23/** CodeMirror and the merge are most of the bundle, and only editing needs them. */ 24function load () :Promise < Loaded > { 25loading ??= Promise . all ([ import ( "./editor.ts" ), import ( "./merge.ts" )]). then ( 26([ editor , merge ]) => loaded = { diffEditor :editor . diffEditor , merge3 :merge . merge3 }, 27( error ) => { 28loading = null ; 29throw new Error ( 30`Could not load the editor; reload the page. ( ${ 31error instanceof Error ? error . message : error 32} )` , 33); 34}, 35); 36return loading ; 37} 38 39type Editing = { 40draft :Draft ; 41element :HTMLElement ; 42editor :DiffEditor ; 43status :HTMLElement ; 44save :HTMLButtonElement ; 45cancel :HTMLButtonElement ; 46force :HTMLButtonElement ; 47saving :Promise < boolean > | null ; 48syncing :Promise < void > | null ; 49/** Whether the disk may have changed since the last look. */ 50stale :boolean ; 51/** The disk's version, while it conflicts with the draft. */ 52conflict :OpenFile | null ; 53resolve :HTMLElement ; 54}; 55 56/** How long typing goes before its draft is written down for recovery. */ 57const TYPED_MS = 250 ; 58 59export function editing ( 60redraw :( path :string ) => void , 61refreshed :( path :string ) => void , 62failure :( message :string ) => void , 63) { 64let context :ViewContext ; 65let key :string ; 66let stored :Stored ; 67let ready :Promise < void > = Promise . resolve (); 68let disposed = false ; 69let leaving :Promise < boolean > | null = null ; 70/** A report leaving kicked off without holding navigation open, which a flush still waits for. */ 71let backgroundReport :Promise < void > | null = null ; 72let destination :ViewDestination ; 73let cancelChoice :(() => void ) | null = null ; 74const edits = new Map < string , Editing >(); 75const opening = new Map < string , Promise < OpenFile >>(); 76/** The view's route a draft belongs to: its jj change, or the working copy's empty one. */ 77const routeOf = ( draft :Draft ) => draft . opened . working . change ?? "" ; 78const dirty = ( draft :Draft ) => draft . content !== draft . opened . content || !! draft . pending ; 79const staying = () => 80destination . tid === context . tid && 81destination . extension === "diff" && destination . route === context . route ; 82const exiting = () => destination . tid !== context . tid || destination . extension !== "diff" ; 83/** A keystroke's write, held back a moment: each writes every draft whole, which per key is too slow to type in. */ 84let typed :ReturnType < typeof setTimeout > | undefined ; 85const persist = () => { 86clearTimeout ( typed ); 87typed = undefined ; 88try { 89storeDrafts ( key , stored ); 90} catch { 91failure ( "Draft recovery storage is full. Keep this tab open until your edits are saved." ); 92} 93}; 94 95function close ( path :string ) { 96edits . get ( path )?. editor . destroy (); 97edits . delete ( path ); 98delete stored . drafts [ path ]; 99persist (); 100refreshed ( path ); 101} 102 103async function save ( edit :Editing ) :Promise < boolean > { 104if ( edit . saving ) return await edit . saving ; 105if ( ! dirty ( edit . draft )) { 106close ( edit . draft . opened . file . path ); 107return true ; 108} 109const { draft} = edit ; 110draft . pending ??= { 111session :stored . id , 112id :editId (), 113working :draft . opened . working , 114path :draft . opened . file . path , 115version :draft . opened . version , 116content :draft . content , 117}; 118stored . saved = true ; 119persist (); 120edit . save . disabled = true ; 121edit . cancel . disabled = true ; 122edit . status . textContent = "saving…" ; 123edit . force . hidden = true ; 124let changedOnDisk = false ; 125const task = ( async () => { 126try { 127const saved = await context . request ( 128"save" , 129draft . pending as unknown as JsonValue , 130) as unknown as Saved ; 131draft . opened . content = draft . pending ! . content ; 132draft . opened . version = saved . version ; 133draft . opened . working = saved . working ; 134delete draft . pending ; 135if ( ! stored . saves . some (( receipt ) => receipt . id === saved . id )) stored . saves . push ( saved ); 136stored . saved = true ; 137persist (); 138if ( dirty ( draft )) { 139edit . status . textContent = "unsaved edits" ; 140refreshed ( draft . opened . file . path ); 141} else { 142close ( draft . opened . file . path ); 143} 144return true ; 145} catch ( error ) { 146const message = error instanceof Error ?error . message :String ( error ); 147changedOnDisk = message . includes ( "since you opened it" ); 148edit . force . hidden = ! message . includes ( "would cause conflicts" ); 149edit . status . textContent = edit . force . hidden 150 ?message 151 :` ${ message } Save anyway to keep them?` ; 152return false ; 153} finally { 154edit . save . disabled = false ; 155edit . cancel . disabled = false ; 156edit . saving = null ; 157} 158})(); 159edit . saving = task ; 160const saved = await task ; 161if ( changedOnDisk ) { 162await sync ( edit ); 163const current = edits . get ( draft . opened . file . path ); 164if ( current && ! current . conflict ) { 165current . status . textContent = 166"since you opened this file, it changed. your edits have been merged." ; 167} 168} 169return saved ; 170} 171 172/** Bring changes made on disk since the draft's base into the editor, as a rebase would. */ 173function sync ( edit :Editing ) :Promise < void > { 174edit . stale = true ; 175return edit . syncing ??= ( async () => { 176try { 177while ( edit . stale && ! disposed && edits . get ( edit . draft . opened . file . path ) === edit ) { 178await edit . saving ; 179edit . stale = false ; 180await pull ( edit ); 181} 182} finally { 183edit . syncing = null ; 184} 185})(); 186} 187 188async function pull ( edit :Editing ) { 189const { draft} = edit ; 190const path = draft . opened . file . path ; 191let latest :OpenFile ; 192try { 193latest = await context . request ( "open" , { 194root :draft . opened . working . root , 195 path, 196route :routeOf ( draft ), 197}) as unknown as OpenFile ; 198} catch ( error ) { 199edit . status . textContent = `Could not check the file on disk: ${ 200error instanceof Error ? error . message : error 201} ` ; 202return ; 203} 204if ( disposed || edits . get ( path ) !== edit || edit . saving ) return ; 205if ( latest . version === draft . opened . version ) { 206// a commit rewritten around the file still moves the draft to the new commit 207draft . opened . working = latest . working ; 208return persist (); 209} 210// a save whose answer was lost may have landed, and is then the base 211const base = draft . pending ?. content === latest . content ?latest . content :draft . opened . content ; 212const merged = loaded ! . merge3 ( base , draft . content , latest . content ); 213if ( merged === false ) { 214edit . conflict = latest ; 215edit . resolve . hidden = false ; 216edit . status . textContent = "The file changed on disk where you edited it." ; 217return ; 218} 219adopt ( 220edit , 221latest , 222merged , 223merged === latest . content ?"updated from disk" :"merged changes from disk" , 224false , 225); 226} 227 228/** Take `latest` as the draft's base, with `content` as its text; a new original or working copy remounts it. */ 229function adopt ( 230edit :Editing , 231latest :OpenFile , 232content :string , 233message :string , 234undoable :boolean , 235) { 236const { draft} = edit ; 237const path = draft . opened . file . path ; 238const moved = latest . original !== draft . opened . original || 239( Object . keys ( latest . working ) as ( keyof WorkingCopy )[]). some (( key ) => 240latest . working [ key ] !== draft . opened . working [ key ] 241); 242edit . conflict = null ; 243edit . resolve . hidden = true ; 244delete draft . pending ; 245draft . opened = latest ; 246if ( moved ) { 247edit . editor . destroy (); 248edits . delete ( path ); 249draft . content = content ; 250persist (); 251redraw ( path ); 252} else { 253edit . editor . replace ( content , undoable ); 254draft . content = content ; 255persist (); 256} 257const current = edits . get ( path ); 258if ( current ) current . status . textContent = message ; 259} 260 261function mount ( draft :Draft , layout :Layout ) :Editing { 262const status = < span role = "status" /> as HTMLElement ; 263let escaped = 0 ; 264const surface = < div class = "diff-edit-surface" / > as HTMLElement ; 265const saveButton = ( 266< button 267type = "button" 268class = "primary diff-save" 269title = "Ctrl+Enter" 270_onclick = {() => void save ( edit )} 271> 272save 273< / button> 274) as HTMLButtonElement ; 275const force = ( 276< button 277type = "button" 278class = "link diff-force" 279hidden 280_onclick = {() => { 281if ( draft . pending ) draft . pending . force = true ; 282persist (); 283void save ( edit ); 284}} 285> 286save anyway 287< /button> 288) as HTMLButtonElement ; 289const cancel = ( 290< button 291type = "button" 292class = "link" 293title = "Escape-Escape" 294_onclick = {() => { 295if ( 296! edit . saving && ( ! dirty ( draft ) || document . defaultView ! . confirm ( "Discard this draft?" )) 297) { 298close ( draft . opened . file . path ); 299} 300}} 301> 302cancel 303< /button> 304) as HTMLButtonElement ; 305const element = ( 306< div class = "diff-edit" > 307< div class = "diff-edit-tools" > 308{ status } 309< span class = "diff-conflict" hidden > 310< button 311type = "button" 312class = "link" 313_onclick = {() => 314edit . conflict && adopt ( 315edit , 316edit . conflict , 317draft . content , 318"keeping your changes (press save to replace!)" , 319false , 320)} 321> 322keep mine 323</ button > 324< button 325type =" button " 326class =" link " 327_onclick = {() => 328edit . conflict && adopt ( 329edit , 330edit . conflict , 331edit . conflict . content , 332"taking on-disk changes (press undo to restore!)" , 333true , 334)} 335> 336take disk ' s 337< /button> 338</ span > 339< button 340type = "button" 341class = "link" 342_onclick = {() => { 343void navigator . clipboard . writeText ( draft . content ). catch (() => { 344status . textContent = "Could not copy; select and copy the draft in the editor." ; 345}); 346}} 347> 348copy draft 349< /button> 350{ force } 351{ cancel } 352{ saveButton } 353< /div> 354{ surface } 355< / div> 356) as HTMLElement ; 357const editor = loaded ! . diffEditor ( 358surface , 359{ ... draft . opened , content : draft . content }, 360layout , 361{ 362changed ( content ) { 363escaped = 0 ; 364draft . content = content ; 365status . textContent = dirty ( draft ) ? "unsaved edits" : "saved" ; 366typed ??= setTimeout ( persist , TYPED_MS ); 367}, 368save : () => void save ( edit ), 369// a second press soon after the first is the confirmation that cancel asks for 370escape () { 371if ( edit . saving ) return ; 372if ( Date . now () - escaped < 1500 ) return close ( draft . opened . file . path ); 373escaped = Date . now (); 374status . textContent = dirty ( draft ) 375? "press Escape again to discard your edits" 376: "press Escape again to exit edit mode" ; 377}, 378}, 379); 380const edit : Editing = { 381draft , 382element , 383editor , 384status , 385save : saveButton , 386cancel , 387force , 388saving : null , 389syncing : null , 390stale : false , 391conflict : null , 392resolve : element . querySelector ( ".diff-conflict" ) as HTMLElement , 393}; 394status . textContent = dirty ( draft ) ? "recovered draft · unsaved edits" : "editing new side" ; 395edits . set ( draft . opened . file . path , edit ); 396return edit ; 397} 398 399function choose () : Promise < "save" | "discard" | "stay" > { 400return new Promise (( resolve ) => { 401const dialog = ( 402< dialog class = "diff-leave" > 403< p > You have unsaved diff edits. </ p > 404< / dialog> 405) as HTMLDialogElement ; 406const finish = ( choice : "save" | "discard" | "stay" ) => { 407cancelChoice = null ; 408dialog . close (); 409dialog . remove (); 410resolve ( choice ); 411}; 412cancelChoice = () => finish ( "stay" ); 413for ( 414const [ label , choice ] of [ 415[ "save and leave" , "save" ], 416[ "discard and leave" , "discard" ], 417[ "stay" , "stay" ], 418] as const 419) { 420dialog . append ( 421< button 422type = "button" 423class = { choice === "save" ? "primary" : "" } 424_onclick = {() => finish ( choice )} 425> 426{ label } 427</ button >, 428); 429} 430dialog . addEventListener ( "cancel" , ( event ) => { 431event . preventDefault (); 432finish ( "stay" ); 433}); 434document . body . append ( dialog ); 435dialog . showModal (); 436}); 437} 438 439async function leave () : Promise < boolean > { 440await ready ; 441await Promise . all ([ ... edits . values ()]. map (( edit ) => edit . saving )); 442if ( staying ()) return true ; 443if ( Object . values ( stored . drafts ). some ( dirty )) { 444const choice = await choose (); 445if ( choice === "stay" ) return false ; 446if ( choice === "save" ) { 447try { 448await load (); 449} catch ( error ) { 450failure ( error instanceof Error ? error . message : String ( error )); 451return false ; 452} 453for ( const draft of Object . values ( stored . drafts )) { 454const edit = edits . get ( draft . opened . file . path ) ?? mount ( draft , "unified" ); 455while ( dirty ( draft )) if ( ! await save ( edit )) return false ; 456} 457} 458} 459if ( staying ()) return true ; 460if ( exiting () && stored . saved ) { 461// Reporting saved edits can wait for the agent's turn to end, which must not hold navigation open. 462const id = stored . id ; 463const saves = stored . saves ; 464stored . id = editId (); 465stored . saved = false ; 466stored . saves = []; 467persist (); 468const task = report ( id , saves ); 469backgroundReport = task ; 470void task . finally (() => { 471if ( backgroundReport === task ) backgroundReport = null ; 472}); 473} 474if ( staying ()) return true ; 475for ( const path of Object . keys ( stored . drafts )) close ( path ); 476if ( exiting ()) localStorage . removeItem ( key ); 477return true ; 478} 479 480/** Report saved edits without holding navigation open; a failure stays to retry on a later visit. */ 481async function report ( id : string , saves : Saved [], throwOnFailure = false ) { 482try { 483await context . command ( "diff-finish" , JSON . stringify ({ session : id , saves })); 484} catch ( error ) { 485const message = `Could not report your saved edits: ${ 486error instanceof Error ? error . message : error 487} ` ; 488const restore = () => { 489stored . id = id ; 490stored . saved = true ; 491stored . saves = [ 492... saves , 493... stored . saves . filter (( receipt ) => ! saves . some (( save ) => save . id === receipt . id )), 494]; 495persist (); 496}; 497if ( disposed ) { 498try { 499if ( localStorage . getItem ( key ) === null ) restore (); 500} catch { /* keep the navigation the user already made */ } 501console . error ( `[berry] ${ message } ` ); 502} else { 503restore (); 504failure ( message ); 505} 506if ( throwOnFailure ) throw error instanceof Error ? error : new Error ( message ); 507} 508} 509 510/** 511* Report saved edits while staying in the view, so an idle message sends after them. Dirty drafts stay 512* unsaved; a failure keeps the message in the composer by throwing. 513*/ 514async function flush () : Promise < void > { 515await ready ; 516await Promise . all ([ ... edits . values ()]. map (( edit ) => edit . saving )); 517if ( disposed ) return ; 518if ( backgroundReport ) await backgroundReport ; 519if ( disposed || ! stored ?. saved ) return ; 520const id = stored . id ; 521const saves = stored . saves ; 522stored . id = editId (); 523stored . saved = false ; 524stored . saves = []; 525persist (); 526if ( ! saves . length ) return ; 527await report ( id , saves , true ); 528} 529 530const onUnload = ( event : BeforeUnloadEvent ) => { 531if ( stored && Object . values ( stored . drafts ). some ( dirty )) { 532event . preventDefault (); 533event . returnValue = "" ; 534} 535}; 536const onHide = () => { 537if ( ! stored ) return ; 538stored . closed = true ; 539persist (); 540}; 541globalThis . addEventListener ( "beforeunload" , onUnload ); 542globalThis . addEventListener ( "pagehide" , onHide ); 543 544return { 545update ( next : ViewContext ) { 546context = next ; 547if ( ! key ) { 548key = draftKey ( next . tid ); 549ready = recover ( key , next ). catch (( error ) => failure ( String ( error ))). then (() => { 550stored = loadDrafts ( key ); 551if ( ! disposed ) { for ( const path of Object . keys ( stored . drafts )) redraw ( path ); } 552}); 553} 554}, 555async begin ( file : FileDiff , root : string , line ?: number ) { 556await ready ; 557const route = context . route ; 558try { 559const editor = load (); 560editor . catch (() => {}); 561if ( ! stored . drafts [ file . path ]) { 562const request = opening . get ( file . path ) ?? 563context . request ( "open" , { root , path : file . path , route }) as Promise < OpenFile >; 564opening . set ( file . path , request ); 565const opened = await request . finally (() => opening . delete ( file . path )); 566if ( disposed || context . route !== route ) return ; 567stored . drafts [ file . path ] ??= { opened , content : opened . content }; 568persist (); 569} 570await editor ; 571if ( disposed || context . route !== route ) return ; 572redraw ( file . path ); 573const view = edits . get ( file . path )?. editor . view ; 574if ( view ) { 575if ( line ) { 576view . dispatch ({ 577selection : { 578anchor : view . state . doc . line ( Math . min ( line , view . state . doc . lines )). from , 579}, 580}); 581} 582view . focus (); 583} 584} catch ( error ) { 585failure ( error instanceof Error ? error . message : String ( error )); 586} 587}, 588/** Look at the file on disk again, as when its diff changes. */ 589sync ( path : string ) { 590const edit = edits . get ( path ); 591if ( edit ) void sync ( edit ); 592}, 593has ( path : string , route ?: string ) { 594const draft = stored ?. drafts [ path ]; 595return !! draft && ( route === undefined || routeOf ( draft ) === route ); 596}, 597files ( root : string , route : string ) : FileDiff [] { 598return Object . values ( stored ?. drafts ?? {}). filter (( draft ) => 599draft . opened . working . root === root && routeOf ( draft ) === route 600) 601. map (( draft ) => draft . opened . file ); 602}, 603attach ( body : HTMLElement , file : FileDiff , layout : Layout , route : string ) { 604const draft = stored ?. drafts [ file . path ]; 605if ( ! draft || routeOf ( draft ) !== route ) return false ; 606let edit = edits . get ( file . path ); 607if ( ! edit && ! loaded ) { 608body . replaceChildren (< p class = "meta file-note" > loading editor… < /p>); 609load (). then ( 610() => redraw ( file . path ), 611( error ) => failure ( error instanceof Error ? error . message : String ( error )), 612); 613return true ; 614} 615if ( ! edit ) { 616edit = mount ( draft , layout ); 617void sync ( edit ); 618} 619edit . editor . layout ( layout ); 620if ( ! body . contains ( edit . element )) body . replaceChildren ( edit . element ); 621return true ; 622}, 623beforeLeave ( next : ViewDestination ) { 624destination = next ; 625if ( staying ()) { 626cancelChoice ?. (); 627if ( ! leaving ) return true ; 628} 629leaving ??= leave (). finally (() => { 630leaving = null ; 631}); 632return leaving ; 633}, 634flush , 635dispose () { 636if ( typed !== undefined ) persist (); 637disposed = true ; 638cancelChoice ?. (); 639for ( const edit of edits . values ()) edit . editor . destroy (); 640edits . clear (); 641globalThis . removeEventListener ( "beforeunload" , onUnload ); 642globalThis . removeEventListener ( "pagehide" , onHide ); 643}, 644}; 645}