char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
f92b8f4
main
1import type { Commit , LocatedObject , TreeEntry } from "./git/types.ts" ; 2import { log , objectAt , type PathChange , pathHistory } from "./git/walk.ts" ; 3import { detectLanguage , languageGroup } from "./languages.ts" ; 4import { paginate } from "./pagination.tsx" ; 5import { canonical , encodePath , href , kindAtTip , type Site , type View } from "./route.ts" ; 6import { humanSize , identityDate , looksBinary , looksGenerated , plural , utf8 } from "./util.ts" ; 7 8const TREE = 0o040000 ; 9const SYMLINK = 0o120000 ; 10const GITLINK = 0o160000 ; 11const HISTORY_PREFETCH = 25 ; 12const kindOf = ( entry :TreeEntry ) => entry . mode & 0o170000 ; 13 14async function entriesAt ( site :Site , commit :Commit , path :string []) :Promise < TreeEntry []> { 15const object = await objectAt ( site . repo , commit . tree , path ); 16if ( ! object || ( object . mode & 0o170000 ) !== TREE ) throw new Error ( ` ${ path . join ( "/" )} is not a tree` ); 17return site . repo . tree ( object . oid ); 18} 19 20/** `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone 21* directories reads better as one link than as three clicks */ 22async function collapseLoneDirs ( site :Site , entry :TreeEntry ) :Promise < string []> { 23const names = [ entry . name ]; 24let oid = entry . oid ; 25for (;;) { 26const [ only , ...rest ] = await site . repo . tree ( oid ); 27if ( rest . length > 0 || ! only || kindOf ( only ) !== TREE ) return names ; 28names . push ( only . name ); 29oid = only . oid ; 30} 31} 32 33/** the same shape as the static topbar, minus the ref switcher: the commit 34* panel above already says which commit this is */ 35export async function topbar ( site :Site , view :View , stats :string [], actions :Node [] = []) :Promise < HTMLElement > { 36const { name} = site ; 37const { kind, oid, path} = view ; 38const crumbs = (< nav class = "crumbs" / >) as HTMLElement ; 39const isRoot = kind !== "language" && kind !== "commit" && path . length === 0 ; 40if ( isRoot ) crumbs . append (< span class = "cur" >{ name}< / span>); 41else crumbs . append (< a href = { await href ( site , { kind :"tree" , oid, path :[] })}>{ name }</ a >); 42if ( kind === "language" ) crumbs . append (< span class = "label" > language :</ span >, " " , < span class = "cur" >{ path [ 0 ]}</ span >); 43else if ( kind === "commit" ) crumbs . append (< span class = "label" > commit :</ span >, " " , < span class = "cur" >{ oid . slice ( 0 , 7 )}</ span >); 44else { 45for ( let i = 0 ; i < path . length ; i ++ ) { 46crumbs . append ( " / " ); 47if ( i < path . length - 1 ) { 48crumbs . append (< a href = { await href ( site , { kind :"tree" , oid, path :path . slice ( 0 , i + 1 ) })}>{ path [ i ]}</ a >); 49} else { 50crumbs . append (< span class = "cur" >{ path [ i ]}</ span >); 51if ( kind === "tree" ) crumbs . append ( " /" ); 52} 53} 54} 55 56if ( kind === "tree" || kind === "blob" ) { 57actions . unshift (< a href = { await href ( site , { kind :"history" , oid, path})}> history </ a >); 58} 59return ( 60< header class = "topbar" > 61{ crumbs} 62< span class = "view-stats" >{ stats. map ( stat => < span >{ stat }</ span >)}</ span > 63< span class = "actions" >{ actions}< / span> 64</ header > 65) as HTMLElement ; 66} 67 68function commitPanel ( commit :Commit ) :HTMLElement { 69return ( 70< p class = "snapshot-commit" > 71< span class = "commit-author" >{ commit . author . name }</ span > 72< a class = "commit-message" href = { `#commit/${commit . oid } `}>{ commit . message . split ( "\n" )[ 0 ]}</ a > 73< a class = "snapshot-sha sha" href = { `#commit/${commit . oid } `}>{ commit . oid . slice ( 0 , 7 )}</ a > 74< time >{ identityDate ( commit . author ). slice ( 0 , 10 )}</ time > 75< / p> 76) as HTMLElement ; 77} 78 79export async function treeView ( site : Site , view : View ) : Promise < Node []> { 80const { oid , path } = view ; 81await site . repo . prefetch ([ oid ], { smart : "tree" }); 82const commit = await site . repo . commit ( oid ); 83const entries = await entriesAt ( site , commit , path ); 84// directories first, then byte order, as git and the static pages 85entries . sort (( a , b ) => Number ( kindOf ( a ) !== TREE ) - Number ( kindOf ( b ) !== TREE ) || ( a . name < b . name ? - 1 : a . name > b . name ? 1 : 0 )); 86const folders = entries . filter ( entry => kindOf ( entry ) === TREE ). length ; 87const files = entries . length - folders ; 88const stats = [ folders ? plural ( folders , "folder" ) : "" , files ? plural ( files , "file" ) : "" ]. filter ( Boolean ); 89const rows = await Promise . all ( entries . map ( async entry => { 90const kind = kindOf ( entry ); 91if ( kind === GITLINK ) return { name : ` ${ entry . name } @ ${ entry . oid . slice ( 0 , 7 )} ` }; 92const names = kind === TREE ? await collapseLoneDirs ( site , entry ) : [ entry . name ]; 93const name = names . join ( "/" ) + ( kind === TREE ? "/" : kind === SYMLINK ? "@" : "" ); 94return { name , href : await href ( site , { kind : kind === TREE ? "tree" : "blob" , oid , path : [ ... path , ... names ] }) }; 95})); 96return [ 97commitPanel ( commit ), 98await topbar ( site , view , stats ), 99< h2 class = "file-heading" > files < /h2>, 100listing ( rows ), 101]; 102} 103 104function listing ( rows : Array <{ name : string ; href ?: string }>) : HTMLTableElement { 105const table = (< table class = "list" / >) as HTMLTableElement ; 106for ( const { name , href } of rows ) { 107table . append (< tr >< td >{ href ? < a href = { href }>{ name }</ a > : name }</ td ></ tr >); 108} 109return table ; 110} 111 112/** every file at the commit classified as `language`, flat: a filtered tree 113* would need the filter carried through every directory link */ 114export async function languageView ( site : Site , view : View ) : Promise < Node []> { 115const { oid , path : [ language ] } = view ; 116await site . repo . prefetch ([ oid ], { smart : "tree" }); 117const commit = await site . repo . commit ( oid ); 118const paths : string [][] = []; 119const walk = async ( tree : string , prefix : string []) => { 120for ( const entry of await site . repo . tree ( tree )) { 121const path = [ ... prefix , entry . name ]; 122const kind = kindOf ( entry ); 123if ( kind === TREE ) { 124// trailing slash so the directory is tested as a parent, not a file 125if ( ! looksGenerated ( ` ${ path . join ( "/" )} /` )) await walk ( entry . oid , path ); 126} else if ( kind !== SYMLINK && kind !== GITLINK && ! looksGenerated ( path . join ( "/" ))) { 127const grammar = detectLanguage ( entry . name ); 128if ( grammar && languageGroup ( grammar ) === language ) paths . push ( path ); 129} 130} 131}; 132await walk ( commit . tree , []); 133const rows = await Promise . all ( paths . map ( async path => ({ 134name : path . join ( "/" ), 135href : await href ( site , { kind : "blob" , oid , path }), 136}))); 137return [ 138commitPanel ( commit ), 139await topbar ( site , view , [ plural ( paths . length , "file" )]), 140< h2 class = "file-heading" > files < /h2>, 141listing ( rows ), 142]; 143} 144 145export async function blobView ( site : Site , view : View ) : Promise < Node []> { 146const { oid , path } = view ; 147if ( path . length === 0 ) throw new Error ( "blob path is empty" ); 148const commit = await site . repo . commit ( oid ); 149const object = await objectAt ( site . repo , commit . tree , path ); 150if ( ! object || ( object . mode & 0o170000 ) === TREE || ( object . mode & 0o170000 ) === GITLINK ) { 151throw new Error ( ` ${ path . join ( "/" )} is not a blob` ); 152} 153const data = await site . repo . blob ( object . oid ); 154const raw = < a class = "raw" href = { `${ site . page . base } /raw/${ object . oid } /${ encodePath ( path )} ` }> raw < /a>; 155const symlink = ( object . mode & 0o170000 ) === SYMLINK ; 156const renderable = ! symlink && ! looksBinary ( data ) && data . length <= 1 << 20 ; 157const text = renderable ? utf8 . decode ( data ) : "" ; 158const lines = text . split ( "\n" ); 159if ( lines . at ( - 1 ) === "" ) lines . pop (); 160const stats = symlink 161? [ `symlink → ${ utf8 . decode ( data )} ` ] 162: renderable 163? [ humanSize ( data . length ), plural ( lines . length , "line" )] 164: [ looksBinary ( data ) ? "binary file" : "large file" , humanSize ( data . length )]; 165const head = [ commitPanel ( commit ), await topbar ( site , view , stats , [ raw ])]; 166if ( ! renderable ) return head ; 167 168const source = ( 169< pre class = "code src historical-code" >{ lines . map (( line , i ) => 170< span class = "code-line" > 171< span class = "ln" >{ String ( i + 1 )}< / span> 172< span class = "code-text" >{ line }< /span> 173{ i < lines . length - 1 ? < span class = "code-break" >{ "\n" }< /span> : ""} 174</ span > 175)}</ pre > 176) as HTMLPreElement ; 177void import ( "./highlight.ts" ) 178. then ( module => module . highlightedLines ( path . join ( "/" ), text )) 179. then ( highlighted => { 180if ( highlighted === null ) return ; 181const targets = source . querySelectorAll ( ".code-text" ); 182for ( const [ i , line ] of highlighted . entries ()) targets [ i ]?. replaceChildren ( ... line ); 183}) 184. catch ( err => console . warn ( "arborium:" , err )); 185return [ ... head , source ]; 186} 187 188async function * commitHistory ( 189site : Site , 190start : string , 191scanned : ( count : number ) => void , 192): AsyncGenerator < PathChange > { 193for await ( const commit of log ( site . repo , [ start ], HISTORY_PREFETCH )) { 194scanned ( 1 ); 195yield { commit, object : { oid : commit . tree , mode : TREE } }; 196} 197} 198 199const snapshotKind = ( object : LocatedObject ): "tree" | "blob" => 200( object . mode & 0o170000 ) === TREE ? "tree" : "blob" ; 201 202export async function historyView ( site : Site , view : View ): Promise < Node []> { 203const { oid, path } = view ; 204const commitList = path . length === 0 ; 205const atTip = await kindAtTip ( site , path ); 206const list = (< ol class = "log history-log" / >) as HTMLOListElement ; 207const status = (< p class = "meta history-status" / >) as HTMLElement ; 208const control = (< p class = "meta log-pagination" / >) as HTMLElement ; 209let scanned = 0 ; 210const scannedCommits = ( count : number ) => { 211scanned += count ; 212status . textContent = `scanned ${ plural ( scanned , "commit" )} ` ; 213}; 214paginate < PathChange >({ 215list, 216control, 217open : () => { 218scanned = 0 ; 219return commitList 220? commitHistory ( site , oid , scannedCommits ) 221: pathHistory ( site . repo , [ oid ], path , scannedCommits ); 222}, 223key : change => change . commit . oid , 224render : change => { 225// a path's history leads to the path as it was; the commit list, to the commits 226const target = ! commitList && change . object 227? canonical ( site . page , { kind : snapshotKind ( change . object ), oid : change . commit . oid , path }, atTip ) 228: `#commit/ ${ change . commit . oid } ` ; 229const who = ( 230< span class = "who" > 231< span >{ change . commit . author . name }</ span > 232< time >{ identityDate ( change . commit . author ). slice ( 0 , 10 )}</ time > 233< / span > 234) as HTMLElement; 235if (!change.object) who.append( < span class = " deleted "> deleted </ span > ); 236const item = ( < li /> ) as HTMLLIElement; 237if (change.commit.changeId) { 238item . append (< a class = "cid" href = { target }>{ change . commit . changeId . slice ( 0 , 8 )}</ a >, " " ); 239} 240item . append ( 241< a class = "sha" href = { target }>{ change . commit . oid . slice ( 0 , 7 )}< / a>, 242who , 243< span class = "msg" >{ change . commit . message . split ( "\n" )[ 0 ]}</ span >, 244); 245return item ; 246}, 247onShow: count => { 248status . replaceChildren (< span >{ plural ( count , commitList ? "commit" : "change" )}</ span >); 249}, 250onError : err => { 251status . textContent = `history failed: ${ String ( err )} ` ; 252}, 253}); 254 255return [ 256await topbar ( site , view , [ commitList ? "commit history" : "change history" ]), 257status , 258list , 259control , 260]; 261}