Skip to content

Commit 3c5a935

Browse files
committed
Organize diff viewer state declaration, remove parallel arrays for mutable file state
1 parent 12efdfb commit 3c5a935

14 files changed

+124
-96
lines changed

web/src/lib/components/diff/AddedOrRemovedImage.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { getDimensions } from "$lib/image";
33
import AddedOrRemovedImageLabel from "$lib/components/diff/AddedOrRemovedImageLabel.svelte";
4-
import type { AddOrRemove } from "$lib/diff-viewer-multi-file.svelte";
4+
import type { AddOrRemove } from "$lib/diff-viewer.svelte";
55
66
interface Props {
77
file: string;

web/src/lib/components/diff/AddedOrRemovedImageLabel.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import { type ImageDimensions } from "$lib/image";
3-
import type { AddOrRemove } from "$lib/diff-viewer-multi-file.svelte";
3+
import type { AddOrRemove } from "$lib/diff-viewer.svelte";
44
55
interface Props {
66
dims: Promise<ImageDimensions> | ImageDimensions;

web/src/lib/diff-viewer-multi-file.svelte.ts renamed to web/src/lib/diff-viewer.svelte.ts

Lines changed: 89 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,28 @@ export const staticSidebar = new MediaQuery("(width >= 64rem)");
2525

2626
export 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

4546
export 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

7780
export 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

8593
export 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

136144
const 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

189197
export 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

595620
export class SearchResults {
596621
static EMPTY = new SearchResults(new Map(), 0, new Map(), new Map());

web/src/lib/github.svelte.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { browser } from "$app/environment";
22
import type { components } from "@octokit/openapi-types";
33
import { parseMultiFilePatch, trimCommitHash } from "$lib/util";
4-
import { LoadingState, makeImageDetails } from "$lib/diff-viewer-multi-file.svelte";
4+
import { LoadingState, makeImageDetails } from "$lib/diff-viewer.svelte";
55
import { PUBLIC_GITHUB_APP_NAME, PUBLIC_GITHUB_CLIENT_ID } from "$env/static/public";
66

77
export const GITHUB_USERNAME_KEY = "github_username";
@@ -10,19 +10,19 @@ export const GITHUB_TOKEN_EXPIRES_KEY = "github_token_expires";
1010

1111
export const githubUsername: { value: string | null } = $state({ value: null });
1212

13-
export type GithubDiff = {
13+
export interface GithubDiff {
1414
owner: string;
1515
repo: string;
1616
base: string;
1717
head: string;
1818
description: string;
1919
backlink: string;
20-
};
20+
}
2121

22-
export type GithubDiffResult = {
22+
export interface GithubDiffResult {
2323
info: Promise<GithubDiff>;
2424
response: Promise<string>;
25-
};
25+
}
2626

2727
if (browser) {
2828
githubUsername.value = localStorage.getItem(GITHUB_USERNAME_KEY);
@@ -72,12 +72,12 @@ export type GithubPR = components["schemas"]["pull-request"];
7272
export type FileStatus = "added" | "removed" | "modified" | "renamed" | "renamed_modified";
7373
export type GithubUser = components["schemas"]["private-user"];
7474
export type GithubCommitDetails = components["schemas"]["commit"];
75-
export type GithubTokenResponse = {
75+
export interface GithubTokenResponse {
7676
access_token: string;
7777
token_type: string;
7878
scope: string;
7979
expires_in: number;
80-
};
80+
}
8181

8282
export async function fetchGithubUserToken(code: string): Promise<GithubTokenResponse> {
8383
const response = await fetch(new URL(`${window.location.origin}/github-token?code=${code}`), {

web/src/lib/image.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
export type ImageDimensions = { width: number; height: number };
1+
export interface ImageDimensions {
2+
width: number;
3+
height: number;
4+
}
25

36
export async function getDimensions(src: string): Promise<ImageDimensions> {
47
const res = await fetch(src);

0 commit comments

Comments
 (0)