|
2 | 2 | import { |
3 | 3 | type ConciseDiffViewProps, |
4 | 4 | ConciseDiffViewState, |
| 5 | + type DiffViewerPatchHunk, |
5 | 6 | innerPatchLineTypeProps, |
6 | 7 | type InnerPatchLineTypeProps, |
7 | 8 | makeSearchSegments, |
|
13 | 14 | type SearchSegment, |
14 | 15 | } from "$lib/components/diff/concise-diff-view.svelte"; |
15 | 16 | import Spinner from "$lib/components/Spinner.svelte"; |
16 | | - import { onDestroy } from "svelte"; |
17 | 17 | import { type MutableValue } from "$lib/util"; |
18 | 18 | import { box } from "svelte-toolbelt"; |
| 19 | + import { boolAttr } from "runed"; |
19 | 20 |
|
20 | 21 | let { |
21 | 22 | rawPatchContent, |
|
28 | 29 | searchQuery, |
29 | 30 | searchMatchingLines, |
30 | 31 | activeSearchResult = -1, |
| 32 | + jumpToSearchResult = $bindable(false), |
31 | 33 | cache, |
32 | 34 | cacheKey, |
| 35 | + unresolvedSelection, |
| 36 | + selection = $bindable(), |
| 37 | + jumpToSelection = $bindable(false), |
33 | 38 | }: ConciseDiffViewProps<K> = $props(); |
34 | 39 |
|
| 40 | + const uid = $props.id(); |
| 41 | +
|
35 | 42 | const parsedPatch = $derived.by(() => { |
36 | 43 | if (rawPatchContent !== undefined) { |
37 | 44 | return parseSinglePatch(rawPatchContent); |
|
42 | 49 | }); |
43 | 50 |
|
44 | 51 | const view = new ConciseDiffViewState({ |
| 52 | + rootElementId: uid, |
| 53 | +
|
45 | 54 | patch: box.with(() => parsedPatch), |
46 | 55 | syntaxHighlighting: box.with(() => syntaxHighlighting), |
47 | 56 | syntaxHighlightingTheme: box.with(() => syntaxHighlightingTheme), |
48 | 57 | omitPatchHeaderOnlyHunks: box.with(() => omitPatchHeaderOnlyHunks), |
49 | 58 | wordDiffs: box.with(() => wordDiffs), |
50 | 59 |
|
| 60 | + unresolvedSelection: box.with(() => unresolvedSelection), |
| 61 | + selection: box.with( |
| 62 | + () => selection, |
| 63 | + (v) => (selection = v), |
| 64 | + ), |
| 65 | +
|
51 | 66 | cache: box.with(() => cache), |
52 | 67 | cacheKey: box.with(() => cacheKey), |
53 | 68 | }); |
|
60 | 75 | } |
61 | 76 | } |
62 | 77 |
|
63 | | - let searchResultElements: HTMLSpanElement[] = $state([]); |
64 | | - let didInitialJump = $state(false); |
65 | | - let scheduledJump: ReturnType<typeof setTimeout> | undefined = undefined; |
66 | | - $effect(() => { |
67 | | - if (didInitialJump) { |
68 | | - return; |
69 | | - } |
70 | | - if (activeSearchResult >= 0 && searchResultElements[activeSearchResult] !== undefined) { |
71 | | - const element = searchResultElements[activeSearchResult]; |
72 | | - const anchorElement = element.closest("tr"); |
73 | | - // This is an exceptionally stupid and unreliable hack, but at least |
74 | | - // jumping to a result in a not-yet-loaded file works most of the time with a delay |
75 | | - // instead of never. |
76 | | - scheduledJump = setTimeout(() => { |
77 | | - if (scheduledJump !== undefined) { |
78 | | - clearTimeout(scheduledJump); |
79 | | - scheduledJump = undefined; |
80 | | - } |
81 | | -
|
82 | | - if (anchorElement !== null) { |
83 | | - anchorElement.scrollIntoView({ block: "center", inline: "center" }); |
84 | | - } |
85 | | - }, 200); |
86 | | - didInitialJump = true; |
87 | | - } |
88 | | - }); |
89 | | - onDestroy(() => { |
90 | | - if (scheduledJump !== undefined) { |
91 | | - clearTimeout(scheduledJump); |
92 | | - scheduledJump = undefined; |
93 | | - } |
94 | | - }); |
95 | | -
|
96 | 78 | let searchSegments: Promise<SearchSegment[][][]> = $derived.by(async () => { |
97 | 79 | if (!searchQuery || !searchMatchingLines) { |
98 | 80 | return []; |
|
134 | 116 | } |
135 | 117 | return segments; |
136 | 118 | }); |
| 119 | +
|
| 120 | + let selectionMidpoint = $derived.by(() => { |
| 121 | + if (!selection) return null; |
| 122 | + const startIdx = selection.start.idx; |
| 123 | + const endIdx = selection.end.idx; |
| 124 | + return Math.floor((startIdx + endIdx) / 2); |
| 125 | + }); |
| 126 | +
|
| 127 | + let heightEstimateRem = $derived.by(() => { |
| 128 | + if (!parsedPatch) return 1.25; |
| 129 | + const rawLineCount = parsedPatch.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0); |
| 130 | + const headerAndSpacerLines = parsedPatch.hunks.length * 2; |
| 131 | + const totalLines = rawLineCount + headerAndSpacerLines; |
| 132 | + return totalLines * 1.25; |
| 133 | + }); |
137 | 134 | </script> |
138 | 135 |
|
139 | 136 | {#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)} |
|
165 | 162 | <span class="inline leading-[0.875rem]"> |
166 | 163 | {#each lineSearchSegments as searchSegment, index (index)} |
167 | 164 | {#if searchSegment.highlighted}<span |
168 | | - bind:this={searchResultElements[searchSegment.id ?? -1]} |
| 165 | + {@attach (element) => { |
| 166 | + if (jumpToSearchResult && searchSegment.id === activeSearchResult) { |
| 167 | + element.scrollIntoView({ block: "center", inline: "center" }); |
| 168 | + jumpToSearchResult = false; |
| 169 | + // See similar code & comment below around jumping to selections |
| 170 | + //const scheduledJump = setTimeout(() => { |
| 171 | + // jumpToSearchResult = false; |
| 172 | + // element.scrollIntoView({ block: "center", inline: "center" }); |
| 173 | + //}, 200); |
| 174 | + //return () => { |
| 175 | + // jumpToSearchResult = false; |
| 176 | + // clearTimeout(scheduledJump); |
| 177 | + //}; |
| 178 | + } |
| 179 | + }} |
169 | 180 | class={{ |
170 | 181 | "bg-[#d4a72c66]": searchSegment.id !== activeSearchResult, |
171 | 182 | "bg-[#ff9632]": searchSegment.id === activeSearchResult, |
|
186 | 197 | {/await} |
187 | 198 | {/snippet} |
188 | 199 |
|
189 | | -{#snippet renderLine(line: PatchLine, hunkIndex: number, lineIndex: number)} |
| 200 | +{#snippet renderLine(line: PatchLine, hunk: DiffViewerPatchHunk, hunkIndex: number, lineIndex: number)} |
190 | 201 | {@const lineType = patchLineTypeProps[line.type]} |
191 | | - <div class="bg-[var(--hunk-header-bg)]"> |
| 202 | + {@const lineTypeSelectable = line.type !== PatchLineType.HEADER && line.type !== PatchLineType.SPACER} |
| 203 | + <div |
| 204 | + class="bg-[var(--hunk-header-bg)] data-selectable:cursor-pointer" |
| 205 | + data-hunk-idx={hunkIndex} |
| 206 | + data-line-idx={lineIndex} |
| 207 | + data-selectable={boolAttr(lineTypeSelectable)} |
| 208 | + {@attach view.selectable(hunk, hunkIndex, line, lineIndex)} |
| 209 | + > |
192 | 210 | <div class="line-number h-full px-2 select-none {lineType.lineNoClasses}">{getDisplayLineNo(line, line.oldLineNo)}</div> |
193 | 211 | </div> |
194 | | - <div class="bg-[var(--hunk-header-bg)]"> |
195 | | - <div class="line-number h-full px-2 select-none {lineType.lineNoClasses}">{getDisplayLineNo(line, line.newLineNo)}</div> |
| 212 | + <div |
| 213 | + class="bg-[var(--hunk-header-bg)] data-selectable:cursor-pointer" |
| 214 | + data-hunk-idx={hunkIndex} |
| 215 | + data-line-idx={lineIndex} |
| 216 | + data-selectable={boolAttr(lineTypeSelectable)} |
| 217 | + {@attach view.selectable(hunk, hunkIndex, line, lineIndex)} |
| 218 | + > |
| 219 | + <div |
| 220 | + class="selected-indicator line-number h-full px-2 select-none {lineType.lineNoClasses}" |
| 221 | + data-selected={boolAttr(view.isSelected(hunkIndex, lineIndex))} |
| 222 | + > |
| 223 | + {getDisplayLineNo(line, line.newLineNo)} |
| 224 | + </div> |
196 | 225 | </div> |
197 | | - <div class="w-full pl-[1rem] {lineType.classes}"> |
| 226 | + <div |
| 227 | + class="selected-indicator w-full pl-[1rem] {lineType.classes}" |
| 228 | + data-hunk-idx={hunkIndex} |
| 229 | + data-line-idx={lineIndex} |
| 230 | + data-selection-start={boolAttr(view.isSelectionStart(hunkIndex, lineIndex))} |
| 231 | + data-selection-end={boolAttr(view.isSelectionEnd(hunkIndex, lineIndex))} |
| 232 | + {@attach (element) => { |
| 233 | + if (jumpToSelection && selection && selection.hunk === hunkIndex && selectionMidpoint === lineIndex) { |
| 234 | + element.scrollIntoView({ block: "center", inline: "center" }); |
| 235 | + jumpToSelection = false; |
| 236 | + // Need to schedule because otherwise the vlist rendering surrounding elements may shift things |
| 237 | + // and cause the element to scroll to the wrong position |
| 238 | + // This is not 100% reliable but is good enough for now |
| 239 | + //const scheduledJump = setTimeout(() => { |
| 240 | + // jumpToSelection = false; |
| 241 | + // element.scrollIntoView({ block: "center", inline: "center" }); |
| 242 | + //}, 200); |
| 243 | + //return () => { |
| 244 | + // if (scheduledJump) { |
| 245 | + // jumpToSelection = false; |
| 246 | + // clearTimeout(scheduledJump); |
| 247 | + // } |
| 248 | + //}; |
| 249 | + } |
| 250 | + }} |
| 251 | + > |
198 | 252 | {@render lineContentWrapper(line, hunkIndex, lineIndex, lineType, innerPatchLineTypeProps[line.innerPatchLineType])} |
199 | 253 | </div> |
200 | 254 | {/snippet} |
201 | 255 |
|
202 | 256 | {#await Promise.all([view.rootStyle, view.diffViewerPatch])} |
203 | | - <div class="flex items-center justify-center bg-neutral-2 p-4"><Spinner /></div> |
| 257 | + <div class="relative bg-neutral-2" style="min-height: {heightEstimateRem}rem;"> |
| 258 | + <!-- 2.25 rem for file header offset --> |
| 259 | + <div class="sticky top-[2.25rem] flex items-center justify-center p-4"> |
| 260 | + <Spinner /> |
| 261 | + </div> |
| 262 | + </div> |
204 | 263 | {:then [rootStyle, diffViewerPatch]} |
205 | 264 | <div |
| 265 | + id={uid} |
206 | 266 | style={rootStyle} |
207 | 267 | class="diff-content text-patch-line w-full bg-[var(--editor-bg)] font-mono text-xs leading-[1.25rem] text-[var(--editor-fg)] selection:bg-[var(--select-bg)]" |
208 | 268 | data-wrap={lineWrap} |
209 | 269 | > |
210 | 270 | {#each diffViewerPatch.hunks as hunk, hunkIndex (hunkIndex)} |
211 | 271 | {#each hunk.lines as line, lineIndex (lineIndex)} |
212 | | - {@render renderLine(line, hunkIndex, lineIndex)} |
| 272 | + {@render renderLine(line, hunk, hunkIndex, lineIndex)} |
213 | 273 | {/each} |
214 | 274 | {/each} |
215 | 275 | </div> |
|
266 | 326 | left: -0.75rem; |
267 | 327 | top: 0; |
268 | 328 | } |
| 329 | +
|
| 330 | + .selected-indicator[data-selected] { |
| 331 | + box-shadow: inset -4px 0 0 0 var(--hunk-header-fg); |
| 332 | + } |
| 333 | + .selected-indicator[data-selection-start] { |
| 334 | + box-shadow: inset 0 1px 0 0 var(--hunk-header-fg); |
| 335 | + } |
| 336 | + .selected-indicator[data-selection-end] { |
| 337 | + box-shadow: inset 0 -1px 0 0 var(--hunk-header-fg); |
| 338 | + } |
| 339 | + .selected-indicator[data-selection-start][data-selection-end] { |
| 340 | + box-shadow: |
| 341 | + inset 0 1px 0 0 var(--hunk-header-fg), |
| 342 | + inset 0 -1px 0 0 var(--hunk-header-fg); |
| 343 | + } |
269 | 344 | </style> |
0 commit comments