@@ -25,26 +25,28 @@ export const staticSidebar = new MediaQuery("(width >= 64rem)");
2525
2626export type AddOrRemove = "add" | "remove" ;
2727
28- export type CommonFileDetails = {
28+ export interface CommonFileDetails {
29+ index : number ;
2930 fromFile : string ;
3031 toFile : string ;
3132 status : FileStatus ;
32- } ;
33+ }
3334
34- export type TextFileDetails = CommonFileDetails & {
35+ export interface TextFileDetails extends CommonFileDetails {
3536 type : "text" ;
3637 structuredPatch : StructuredPatch ;
3738 patchHeaderDiffOnly : boolean ;
38- } ;
39+ }
3940
40- export type ImageFileDetails = CommonFileDetails & {
41+ export interface ImageFileDetails extends CommonFileDetails {
4142 type : "image" ;
4243 image : ImageDiffDetails ;
43- } ;
44+ }
4445
4546export function makeTextDetails ( fromFile : string , toFile : string , status : FileStatus , patchText : string ) : TextFileDetails {
4647 const patch = parseSinglePatch ( patchText ) ;
4748 return {
49+ index : - 1 ,
4850 type : "text" ,
4951 fromFile,
5052 toFile,
@@ -62,6 +64,7 @@ export function makeImageDetails(
6264 toBlob ?: Promise < Blob > | Blob ,
6365) : ImageFileDetails {
6466 return {
67+ index : - 1 ,
6568 type : "image" ,
6669 fromFile,
6770 toFile,
@@ -76,11 +79,16 @@ export function makeImageDetails(
7679
7780export type FileDetails = TextFileDetails | ImageFileDetails ;
7881
79- export type ImageDiffDetails = {
82+ export interface FileState {
83+ checked : boolean ;
84+ collapsed : boolean ;
85+ }
86+
87+ export interface ImageDiffDetails {
8088 fileA : LazyPromise < string > | null ;
8189 fileB : LazyPromise < string > | null ;
8290 load : boolean ;
83- } ;
91+ }
8492
8593export function requireEitherImage ( details : ImageDiffDetails ) {
8694 if ( details . fileA ) return details . fileA ;
@@ -128,10 +136,10 @@ function compareFileDetails(a: FileDetails, b: FileDetails): number {
128136 return aParts . length - bParts . length ;
129137}
130138
131- export type FileStatusProps = {
139+ export interface FileStatusProps {
132140 iconClasses : string ;
133141 title : string ;
134- } ;
142+ }
135143
136144const addStatusProps : FileStatusProps = {
137145 iconClasses : "iconify octicon--file-added-16 text-green-600" ,
@@ -169,22 +177,22 @@ export function getFileStatusProps(status: FileStatus): FileStatusProps {
169177 }
170178}
171179
172- export type ViewerStatistics = {
180+ export interface ViewerStatistics {
173181 addedLines : number ;
174182 removedLines : number ;
175183 fileAddedLines : number [ ] ;
176184 fileRemovedLines : number [ ] ;
177- } ;
185+ }
178186
179- export type GithubDiffMetadata = {
187+ export interface GithubDiffMetadata {
180188 type : "github" ;
181189 details : GithubDiff ;
182- } ;
190+ }
183191
184- export type FileDiffMetadata = {
192+ export interface FileDiffMetadata {
185193 type : "file" ;
186194 fileName : string ;
187- } ;
195+ }
188196
189197export type DiffMetadata = GithubDiffMetadata | FileDiffMetadata ;
190198
@@ -199,51 +207,40 @@ export class MultiFileDiffViewerState {
199207 return MultiFileDiffViewerState . context . get ( ) ;
200208 }
201209
202- fileTreeFilter : string = $state ( "" ) ;
203- searchQuery : string = $state ( "" ) ;
204- // TODO remove parallel arrays to fix order-dependency issues
205- collapsed : boolean [ ] = $state ( [ ] ) ;
206- checked : boolean [ ] = $state ( [ ] ) ;
207- fileDetails : FileDetails [ ] = $state ( [ ] ) ;
208- diffViewCache : Map < FileDetails , ConciseDiffViewCachedState > = new Map ( ) ;
209- vlist : VList < FileDetails > | undefined = $state ( ) ;
210- tree : TreeState < FileTreeNodeData > | undefined = $state ( ) ;
211- activeSearchResult : ActiveSearchResult | null = $state ( null ) ;
212- sidebarCollapsed = $state ( false ) ;
210+ // Main diff state
213211 diffMetadata : DiffMetadata | null = $state ( null ) ;
214- readonly loadingState : LoadingState = $state ( new LoadingState ( ) ) ;
212+ fileDetails : FileDetails [ ] = $state ( [ ] ) ; // Read-only state
213+ fileStates : FileState [ ] = $state ( [ ] ) ; // Mutable state
214+ readonly stats : ViewerStatistics = $derived ( this . countStats ( ) ) ;
215215
216- readonly fileTreeFilterDebounced = new Debounced ( ( ) => this . fileTreeFilter , 500 ) ;
216+ // Content search state
217+ searchQuery : string = $state ( "" ) ;
217218 readonly searchQueryDebounced = new Debounced ( ( ) => this . searchQuery , 500 ) ;
218- readonly stats : ViewerStatistics = $derived ( this . countStats ( ) ) ;
219+ readonly searchResults : Promise < SearchResults > = $derived ( this . findSearchResults ( ) ) ;
220+
221+ // File tree state
222+ tree : TreeState < FileTreeNodeData > | undefined = $state ( ) ;
223+ fileTreeFilter : string = $state ( "" ) ;
219224 readonly fileTreeRoots : TreeNode < FileTreeNodeData > [ ] = $derived ( makeFileTree ( this . fileDetails ) ) ;
225+ readonly fileTreeFilterDebounced = new Debounced ( ( ) => this . fileTreeFilter , 500 ) ;
220226 readonly filteredFileDetails : FileDetails [ ] = $derived (
221227 this . fileTreeFilterDebounced . current ? this . fileDetails . filter ( ( f ) => this . filterFile ( f ) ) : this . fileDetails ,
222228 ) ;
223- readonly searchResults : Promise < SearchResults > = $derived ( this . findSearchResults ( ) ) ;
224229
225- private constructor ( ) {
226- // Auto-check all patch header diff only diffs
227- $effect ( ( ) => {
228- for ( let i = 0 ; i < this . fileDetails . length ; i ++ ) {
229- const details = this . fileDetails [ i ] ;
230- if ( details . type !== "text" ) {
231- continue ;
232- }
233- if ( details . patchHeaderDiffOnly && this . checked [ i ] === undefined ) {
234- this . checked [ i ] = true ;
235- }
236- }
237- } ) ;
230+ // Misc. component state
231+ diffViewCache : Map < FileDetails , ConciseDiffViewCachedState > = new Map ( ) ;
232+ vlist : VList < FileDetails > | undefined = $state ( ) ;
233+ readonly loadingState : LoadingState = $state ( new LoadingState ( ) ) ;
234+
235+ // Transient state
236+ sidebarCollapsed = $state ( false ) ;
237+ activeSearchResult : ActiveSearchResult | null = $state ( null ) ;
238238
239+ private constructor ( ) {
239240 // Make sure to revoke object URLs when the component is destroyed
240241 onDestroy ( ( ) => this . clearImages ( ) ) ;
241242 }
242243
243- getIndex ( details : FileDetails ) : number {
244- return this . fileDetails . findIndex ( ( f ) => f . fromFile === details . fromFile && f . toFile === details . toFile ) ;
245- }
246-
247244 filterFile ( file : FileDetails ) : boolean {
248245 const queryLower = this . fileTreeFilterDebounced . current . toLowerCase ( ) ;
249246 return file . toFile . toLowerCase ( ) . includes ( queryLower ) || file . fromFile . toLowerCase ( ) . includes ( queryLower ) ;
@@ -253,23 +250,29 @@ export class MultiFileDiffViewerState {
253250 this . fileTreeFilter = "" ;
254251 }
255252
256- toggleCollapse ( index : number ) {
257- this . collapsed [ index ] = ! ( this . collapsed [ index ] || false ) ;
253+ toggleCollapse ( idx : number ) {
254+ const fileState = this . fileStates [ idx ] ;
255+ fileState . collapsed = ! fileState . collapsed ;
258256 }
259257
260258 expandAll ( ) {
261- this . collapsed = [ ] ;
259+ for ( let i = 0 ; i < this . fileStates . length ; i ++ ) {
260+ this . fileStates [ i ] . collapsed = false ;
261+ }
262262 }
263263
264264 collapseAll ( ) {
265- this . collapsed = this . fileDetails . map ( ( ) => true ) ;
265+ for ( let i = 0 ; i < this . fileStates . length ; i ++ ) {
266+ this . fileStates [ i ] . collapsed = true ;
267+ }
266268 }
267269
268- toggleChecked ( index : number ) {
269- this . checked [ index ] = ! this . checked [ index ] ;
270- if ( this . checked [ index ] ) {
270+ toggleChecked ( idx : number ) {
271+ const fileState = this . fileStates [ idx ] ;
272+ fileState . checked = ! fileState . checked ;
273+ if ( fileState . checked ) {
271274 // Auto-collapse on check
272- this . collapsed [ index ] = true ;
275+ fileState . collapsed = true ;
273276 }
274277 }
275278
@@ -280,9 +283,10 @@ export class MultiFileDiffViewerState {
280283 const smooth = options . smooth ?? false ;
281284 const focus = options . focus ?? false ;
282285
283- if ( autoExpand && ! this . checked [ index ] ) {
286+ const fileState = this . fileStates [ index ] ;
287+ if ( autoExpand && ! fileState . checked ) {
284288 // Auto-expand on jump when not checked
285- this . collapsed [ index ] = false ;
289+ fileState . collapsed = false ;
286290 }
287291 this . vlist . scrollToIndex ( index , { align : "start" , smooth } ) ;
288292 if ( focus ) {
@@ -297,8 +301,8 @@ export class MultiFileDiffViewerState {
297301 // https://github.com/inokawa/virtua/discussions/542#discussioncomment-11214618
298302 async scrollToMatch ( file : FileDetails , idx : number ) {
299303 if ( ! this . vlist ) return ;
300- const fileIdx = this . getIndex ( file ) ;
301- this . collapsed [ fileIdx ] = false ;
304+ const fileIdx = file . index ;
305+ this . fileStates [ fileIdx ] . collapsed = false ;
302306 const startIdx = this . vlist . findStartIndex ( ) ;
303307 const endIdx = this . vlist . findEndIndex ( ) ;
304308 if ( fileIdx < startIdx || fileIdx > endIdx ) {
@@ -340,8 +344,7 @@ export class MultiFileDiffViewerState {
340344
341345 private clear ( clearMeta : boolean = true ) {
342346 // Reset state
343- this . collapsed = [ ] ;
344- this . checked = [ ] ;
347+ this . fileStates = [ ] ;
345348 if ( clearMeta ) {
346349 this . diffMetadata = null ;
347350 }
@@ -382,6 +385,7 @@ export class MultiFileDiffViewerState {
382385
383386 // Load patches
384387 const tempDetails : FileDetails [ ] = [ ] ;
388+ const tempStates = new Map < string , FileState > ( ) ;
385389 let lastYield = performance . now ( ) ;
386390 let i = 0 ;
387391 for await ( const details of generator ) {
@@ -391,6 +395,16 @@ export class MultiFileDiffViewerState {
391395 // Pushing directly to the main array causes too many signals to update (lag)
392396 tempDetails . push ( details ) ;
393397
398+ let preChecked = false ;
399+ if ( details . type === "text" ) {
400+ // Pre-check files with only header diff
401+ preChecked = details . patchHeaderDiffOnly ;
402+ }
403+ tempStates . set ( details . fromFile , {
404+ collapsed : false ,
405+ checked : preChecked ,
406+ } ) ;
407+
394408 if ( performance . now ( ) - lastYield > 50 || i % 100 === 0 ) {
395409 await tick ( ) ;
396410 await yieldToBrowser ( ) ;
@@ -400,8 +414,19 @@ export class MultiFileDiffViewerState {
400414 if ( tempDetails . length === 0 ) {
401415 throw new Error ( "No valid patches found in the provided data." ) ;
402416 }
417+
403418 tempDetails . sort ( compareFileDetails ) ;
404419 this . fileDetails . push ( ...tempDetails ) ;
420+
421+ for ( let i = 0 ; i < tempDetails . length ; i ++ ) {
422+ const details = tempDetails [ i ] ;
423+ details . index = i ;
424+ const state = tempStates . get ( details . fromFile ) ;
425+ if ( state ) {
426+ this . fileStates . push ( state ) ;
427+ }
428+ }
429+
405430 return true ;
406431 } catch ( e ) {
407432 this . clear ( ) ; // Clear any partially loaded state
@@ -587,10 +612,10 @@ export class LoadingState {
587612 }
588613}
589614
590- export type ActiveSearchResult = {
615+ export interface ActiveSearchResult {
591616 file : FileDetails ;
592617 idx : number ;
593- } ;
618+ }
594619
595620export class SearchResults {
596621 static EMPTY = new SearchResults ( new Map ( ) , 0 , new Map ( ) , new Map ( ) ) ;
0 commit comments