11import { highlightCode } from "./syntax-highlight.js" ;
2+ import { onThemeChange } from "./theme.js" ;
23import { wrapRanges , stringWidth } from "./view/height.js" ;
34
45export interface StyledSegment {
@@ -742,8 +743,106 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null {
742743 * heading checks stay in that order so a settled block can freeze while the
743744 * tail is still streaming. An unclosed fence consumes to the end; blank lines
744745 * before the tail collapse to one.
746+ *
747+ * Repeat calls keep the previous frozen string object. An unchanged body
748+ * returns the cached split; an append reuses that frozen reference and only
749+ * rebuilds the live tail. The cache stores those parse inputs (text, width),
750+ * not painted nodes, and drops them when the theme changes.
745751 */
746- export function splitAtSettledBlock ( withheld : string ) : MarkdownSplit | null {
752+ const PARSE_INPUT_CACHE_LIMIT = 32 ;
753+
754+ interface SettledSplitEntry {
755+ input : string ;
756+ width : number ;
757+ split : MarkdownSplit | null ;
758+ restStart : number ;
759+ }
760+
761+ const parseInputCache : SettledSplitEntry [ ] = [ ] ;
762+ let settledBlockFullSplitCount = 0 ;
763+
764+ /** Full-body split walks since process start. Appends and cache hits do not count. */
765+ export function settledBlockFullSplits ( ) : number {
766+ return settledBlockFullSplitCount ;
767+ }
768+
769+ /** Drop cached parse inputs. Theme changes call this; the strings are not styled. */
770+ export function clearMarkdownParseInputCache ( ) : void {
771+ parseInputCache . length = 0 ;
772+ }
773+
774+ onThemeChange ( ( ) => {
775+ clearMarkdownParseInputCache ( ) ;
776+ } ) ;
777+
778+ function rememberSplit (
779+ entry : SettledSplitEntry ,
780+ drop ?: SettledSplitEntry ,
781+ ) : void {
782+ if ( drop !== undefined ) {
783+ const index = parseInputCache . indexOf ( drop ) ;
784+ if ( index >= 0 ) parseInputCache . splice ( index , 1 ) ;
785+ }
786+ parseInputCache . push ( entry ) ;
787+ if ( parseInputCache . length > PARSE_INPUT_CACHE_LIMIT ) parseInputCache . shift ( ) ;
788+ }
789+
790+ function findCachedSplit (
791+ text : string ,
792+ width : number ,
793+ ) : SettledSplitEntry | undefined {
794+ for ( let i = parseInputCache . length - 1 ; i >= 0 ; i -- ) {
795+ const entry = parseInputCache [ i ] ;
796+ if ( entry === undefined || entry . width !== width || entry . input !== text ) {
797+ continue ;
798+ }
799+ if ( i !== parseInputCache . length - 1 ) {
800+ parseInputCache . splice ( i , 1 ) ;
801+ parseInputCache . push ( entry ) ;
802+ }
803+ return entry ;
804+ }
805+ return undefined ;
806+ }
807+
808+ function findAppendBase (
809+ text : string ,
810+ width : number ,
811+ ) : SettledSplitEntry | undefined {
812+ for ( let i = parseInputCache . length - 1 ; i >= 0 ; i -- ) {
813+ const entry = parseInputCache [ i ] ;
814+ if ( entry === undefined || entry . width !== width || entry . split === null ) {
815+ continue ;
816+ }
817+ if ( text . length <= entry . input . length || ! text . startsWith ( entry . input ) ) {
818+ continue ;
819+ }
820+ return entry ;
821+ }
822+ return undefined ;
823+ }
824+
825+ function restStartFor ( withheld : string , split : MarkdownSplit | null ) : number {
826+ if ( split === null || ! withheld . startsWith ( split . frozen ) ) return 0 ;
827+ const end = split . frozen . length ;
828+ return end < withheld . length && withheld [ end ] === "\n" ? end + 1 : end ;
829+ }
830+
831+ function firstContentOffset ( text : string ) : number {
832+ let start = 0 ;
833+ for ( let i = 0 ; i <= text . length ; i ++ ) {
834+ if ( i !== text . length && text [ i ] !== "\n" ) continue ;
835+ if ( text . slice ( start , i ) . trim ( ) . length > 0 ) return start ;
836+ start = i + 1 ;
837+ }
838+ return text . length ;
839+ }
840+
841+ function computeSettledSplit (
842+ withheld : string ,
843+ account : boolean ,
844+ ) : { split : MarkdownSplit | null ; restStart : number } {
845+ if ( account ) settledBlockFullSplitCount += 1 ;
747846 const lines = withheld . split ( "\n" ) ;
748847 const boundaries = new Set < number > ( ) ;
749848 let i = 0 ;
@@ -803,13 +902,78 @@ export function splitAtSettledBlock(withheld: string): MarkdownSplit | null {
803902 const rest = lines . slice ( boundary + 1 ) ;
804903 const firstContent = rest . findIndex ( ( entry ) => entry . trim ( ) . length > 0 ) ;
805904 if ( firstContent === - 1 ) continue ;
806- return settledSplit (
905+ const split = settledSplit (
807906 lines . slice ( 0 , boundary + 1 ) . join ( "\n" ) ,
808907 rest ,
809908 firstContent ,
810909 ) ;
910+ return { split, restStart : restStartFor ( withheld , split ) } ;
911+ }
912+ return { split : null , restStart : 0 } ;
913+ }
914+
915+ function tryExtendSplit (
916+ prev : SettledSplitEntry ,
917+ withheld : string ,
918+ width : number ,
919+ ) : SettledSplitEntry | undefined {
920+ const split = prev . split ;
921+ if ( split === null || ! withheld . startsWith ( split . frozen ) ) return undefined ;
922+ const candidateLive = split . live + withheld . slice ( prev . input . length ) ;
923+ const moved = computeSettledSplit ( candidateLive , false ) ;
924+ if ( moved . split === null ) {
925+ return {
926+ input : withheld ,
927+ width,
928+ split : {
929+ frozen : split . frozen ,
930+ live : candidateLive ,
931+ gapRows : split . gapRows ,
932+ } ,
933+ restStart : prev . restStart ,
934+ } ;
935+ }
936+ const tail = withheld . slice ( prev . restStart ) ;
937+ const at = firstContentOffset ( tail ) ;
938+ const marker = moved . split . frozen ;
939+ if ( ! tail . startsWith ( marker , at ) ) return undefined ;
940+ const frozenEnd = prev . restStart + at + marker . length ;
941+ const frozen = withheld . slice ( 0 , frozenEnd ) ;
942+ const next : MarkdownSplit = {
943+ frozen,
944+ live : moved . split . live ,
945+ gapRows : moved . split . gapRows ,
946+ } ;
947+ return {
948+ input : withheld ,
949+ width,
950+ split : next ,
951+ restStart : restStartFor ( withheld , next ) ,
952+ } ;
953+ }
954+
955+ export function splitAtSettledBlock (
956+ withheld : string ,
957+ width = 0 ,
958+ ) : MarkdownSplit | null {
959+ const cached = findCachedSplit ( withheld , width ) ;
960+ if ( cached ) return cached . split ;
961+ const base = findAppendBase ( withheld , width ) ;
962+ if ( base ) {
963+ const extended = tryExtendSplit ( base , withheld , width ) ;
964+ if ( extended ) {
965+ rememberSplit ( extended , base ) ;
966+ return extended . split ;
967+ }
811968 }
812- return null ;
969+ const computed = computeSettledSplit ( withheld , true ) ;
970+ rememberSplit ( {
971+ input : withheld ,
972+ width,
973+ split : computed . split ,
974+ restStart : computed . restStart ,
975+ } ) ;
976+ return computed . split ;
813977}
814978
815979/** Paint state for one streaming markdown row across repaints. */
@@ -833,6 +997,8 @@ export function nextStreamMarkdownState(
833997) : { state : StreamMarkdownSnapshot ; paintFrozen : boolean } {
834998 const state : StreamMarkdownSnapshot = { content, frozen, width, streaming } ;
835999 if ( prev === null ) return { state, paintFrozen : true } ;
1000+ // Frozen text is the cached string object, so an unchanged boundary does
1001+ // not compare a freshly joined copy of the prefix.
8361002 const paintFrozen =
8371003 prev . frozen !== frozen ||
8381004 prev . width !== width ||
0 commit comments