char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
2396910
main
1import { EditorState , type Extension , RangeSet , StateField , Transaction } from "@codemirror/state" ; 2import { 3Decoration , 4type DecorationSet , 5EditorView , 6gutterLineClass , 7GutterMarker , 8gutterWidgetClass , 9keymap , 10lineNumbers , 11} from "@codemirror/view" ; 12import { 13defaultKeymap , 14history , 15historyField , 16historyKeymap , 17indentWithTab , 18isolateHistory , 19} from "@codemirror/commands" ; 20import { diff , getChunks , MergeView , unifiedMergeView } from "@codemirror/merge" ; 21import type { OpenFile } from "./edit-types.ts" ; 22import { highlighting } from "./highlight.ts" ; 23 24export type Layout = "unified" | "split" ; 25 26class RowMarker extends GutterMarker { 27constructor ( override readonly elementClass :string ) { 28super (); 29} 30} 31 32const addedRow = new RowMarker ( "cm-addedRow" ); 33const removedRow = new RowMarker ( "cm-removedRow" ); 34const wholeLine = Decoration . line ({ class :"cm-wholeChange" }); 35 36/** Tints whole rows as the read-only diff does, and marks lines a chunk adds or removes outright, whose words need no highlight. */ 37const rows = StateField . define <{ lines :DecorationSet ; gutter :RangeSet < GutterMarker > }>({ 38create :( state ) => changedRows ( state ), 39update :( value , transaction ) => 40getChunks ( transaction . state )?. chunks === getChunks ( transaction . startState )?. chunks 41 ?value 42 :changedRows ( transaction . state ), 43provide :( field ) => [ 44EditorView . decorations . from ( field , ( value ) => value . lines ), 45gutterLineClass . from ( field , ( value ) => value . gutter ), 46], 47}); 48 49/** Unified deletions are widgets between lines, whose gutter rows need the tint too. */ 50const removedWidgetRows = gutterWidgetClass . of (( view , _widget , block ) => { 51const found = getChunks ( view . state ); 52return found ?. side === null && 53found . chunks . some (( chunk ) => chunk . fromB === block . from && chunk . fromA !== chunk . toA ) 54 ?removedRow 55 :null ; 56}); 57 58function changedRows ( state :EditorState ) { 59const found = getChunks ( state ); 60const lines = []; 61const gutter = []; 62const a = found ?. side === "a" ; 63for ( const chunk of found ?. chunks ?? []) { 64const [ from , to ] = a ?[ chunk . fromA , chunk . toA ] :[ chunk . fromB , chunk . toB ]; 65if ( from === to ) continue ; 66const whole = a ?chunk . fromB === chunk . toB :chunk . fromA === chunk . toA ; 67for ( let pos = from ; pos < to && pos <= state . doc . length ;) { 68const line = state . doc . lineAt ( pos ); 69gutter . push (( a ?removedRow :addedRow ). range ( line . from )); 70if ( whole ) lines . push ( wholeLine . range ( line . from )); 71pos = line . to + 1 ; 72} 73} 74return { lines :Decoration . set ( lines ), gutter :RangeSet . of ( gutter ) }; 75} 76 77export function diffEditor ( 78parent :HTMLElement , 79opened :OpenFile , 80layout :Layout , 81on :{ 82changed ( content :string ) :void ; 83save () :void ; 84/** Escape, unless codemirror had a use for it, such as collapsing several selections. */ 85escape () :void ; 86}, 87) { 88const separator = opened . content . includes ( "\r\n" ) ?"\r\n" :"\n" ; 89const normalize = ( text :string ) => text . replace ( / \r\n / g , "\n" ); 90const original = normalize ( opened . original ); 91let view :EditorView ; 92let merge :MergeView | null = null ; 93const content = () => view . state . doc . toString (). replace ( / \n / g , separator ); 94const config = { 95collapseUnchanged :{ margin :3 , minSize :4 }, 96// the default scan limit turns sparse edits in long files into one replacement 97diffConfig :{ timeout :100 }, 98}; 99const common :Extension [] = [ 100highlighting ( opened . file . path ), 101lineNumbers (), 102EditorView . lineWrapping , 103rows , 104removedWidgetRows , 105]; 106 107function mount ( previous ?:EditorState ) { 108const extensions :Extension [] = [ 109common , 110 ...( previous ?[ historyField . init (() => previous . field ( historyField ))] :[]), 111history (), 112keymap . of ([ 113{ key :"Mod-Enter" , run :() => ( on . save (), true ) }, 114{ key :"Mod-s" , run :() => ( on . save (), true ) }, 115{ 116key :"Escape" , 117run :( view ) => view . state . selection . ranges . length === 1 && ( on . escape (), true ), 118}, 119 ...defaultKeymap , 120 ...historyKeymap , 121indentWithTab , 122]), 123EditorView . contentAttributes . of ({ "aria-label" :`Edit ${ opened . file . path } ` }), 124EditorView . updateListener . of (( update ) => { 125if ( update . docChanged ) on . changed ( content ()); 126}), 127]; 128const doc = previous ?. doc ?? normalize ( opened . content ); 129const selection = previous ?. selection ; 130if ( layout === "split" ) { 131merge = new MergeView ({ 132 parent, 133 ...config , 134a :{ 135doc :original , 136extensions :[ 137common , 138EditorState . readOnly . of ( true ), 139EditorView . editable . of ( false ), 140EditorView . contentAttributes . of ({ "aria-label" :`Original ${ opened . file . path } ` }), 141], 142}, 143b :{ doc, selection, extensions}, 144}); 145view = merge . b ; 146} else { 147view = new EditorView ({ 148 parent, 149state :EditorState . create ({ 150 doc, 151 selection, 152extensions :[ 153extensions , 154unifiedMergeView ({ 155 original, 156 ...config , 157mergeControls :false , 158syntaxHighlightDeletions :false , 159}), 160], 161}), 162}); 163} 164} 165 166function destroy () { 167if ( merge ) merge . destroy (); 168else view . destroy (); 169merge = null ; 170} 171 172mount (); 173return { 174get view () { 175return view ; 176}, 177 content, 178/** Change the text to `next` by its differences, so the selection keeps its place. */ 179replace ( next :string , undoable :boolean ) { 180const current = view . state . doc . toString (); 181const target = normalize ( next ); 182if ( current === target ) return ; 183view . dispatch ({ 184changes :diff ( current , target ). map (( change ) => ({ 185from :change . fromA , 186to :change . toA , 187insert :target . slice ( change . fromB , change . toB ), 188})), 189annotations :undoable ?isolateHistory . of ( "full" ) :Transaction . addToHistory . of ( false ), 190}); 191}, 192layout ( next :Layout ) { 193if ( next === layout ) return ; 194const previous = view . state ; 195const focused = view . hasFocus ; 196destroy (); 197parent . replaceChildren (); 198layout = next ; 199mount ( previous ); 200if ( focused ) view . focus (); 201}, 202 destroy, 203}; 204} 205 206export type DiffEditor = ReturnType < typeof diffEditor >;