Skip to content

Commit 0e831cb

Browse files
committed
Allow selecting patch line (ranges) and linking to them
1 parent dd93e61 commit 0e831cb

File tree

9 files changed

+541
-103
lines changed

9 files changed

+541
-103
lines changed

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

Lines changed: 82 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
type ConciseDiffViewProps,
44
ConciseDiffViewState,
5+
type DiffViewerPatchHunk,
56
innerPatchLineTypeProps,
67
type InnerPatchLineTypeProps,
78
makeSearchSegments,
@@ -13,9 +14,10 @@
1314
type SearchSegment,
1415
} from "$lib/components/diff/concise-diff-view.svelte";
1516
import Spinner from "$lib/components/Spinner.svelte";
16-
import { onDestroy } from "svelte";
17+
import { onMount } from "svelte";
1718
import { type MutableValue } from "$lib/util";
1819
import { box } from "svelte-toolbelt";
20+
import { boolAttr } from "runed";
1921
2022
let {
2123
rawPatchContent,
@@ -28,8 +30,12 @@
2830
searchQuery,
2931
searchMatchingLines,
3032
activeSearchResult = -1,
33+
jumpToSearchResult = $bindable(false),
3134
cache,
3235
cacheKey,
36+
unresolvedSelection,
37+
selection = $bindable(),
38+
jumpToSelection = $bindable(false),
3339
}: ConciseDiffViewProps<K> = $props();
3440
3541
const parsedPatch = $derived.by(() => {
@@ -48,6 +54,12 @@
4854
omitPatchHeaderOnlyHunks: box.with(() => omitPatchHeaderOnlyHunks),
4955
wordDiffs: box.with(() => wordDiffs),
5056
57+
unresolvedSelection: box.with(() => unresolvedSelection),
58+
selection: box.with(
59+
() => selection,
60+
(v) => (selection = v),
61+
),
62+
5163
cache: box.with(() => cache),
5264
cacheKey: box.with(() => cacheKey),
5365
});
@@ -60,39 +72,6 @@
6072
}
6173
}
6274
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-
9675
let searchSegments: Promise<SearchSegment[][][]> = $derived.by(async () => {
9776
if (!searchQuery || !searchMatchingLines) {
9877
return [];
@@ -134,6 +113,13 @@
134113
}
135114
return segments;
136115
});
116+
117+
let selectionMidpoint = $derived.by(() => {
118+
if (!selection) return null;
119+
const startIdx = selection.start.idx;
120+
const endIdx = selection.end.idx;
121+
return Math.floor((startIdx + endIdx) / 2);
122+
});
137123
</script>
138124

139125
{#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)}
@@ -165,7 +151,20 @@
165151
<span class="inline leading-[0.875rem]">
166152
{#each lineSearchSegments as searchSegment, index (index)}
167153
{#if searchSegment.highlighted}<span
168-
bind:this={searchResultElements[searchSegment.id ?? -1]}
154+
{@attach (element) => {
155+
onMount(() => {
156+
if (jumpToSearchResult && searchSegment.id === activeSearchResult) {
157+
jumpToSearchResult = false;
158+
// See similar code & comment below around jumping to selections
159+
const scheduledJump = setTimeout(() => {
160+
element.scrollIntoView({ block: "center", inline: "center" });
161+
}, 100);
162+
return () => {
163+
clearTimeout(scheduledJump);
164+
};
165+
}
166+
});
167+
}}
169168
class={{
170169
"bg-[#d4a72c66]": searchSegment.id !== activeSearchResult,
171170
"bg-[#ff9632]": searchSegment.id === activeSearchResult,
@@ -186,15 +185,42 @@
186185
{/await}
187186
{/snippet}
188187

189-
{#snippet renderLine(line: PatchLine, hunkIndex: number, lineIndex: number)}
188+
{#snippet renderLine(line: PatchLine, hunk: DiffViewerPatchHunk, hunkIndex: number, lineIndex: number)}
190189
{@const lineType = patchLineTypeProps[line.type]}
191-
<div class="bg-[var(--hunk-header-bg)]">
190+
<div class="bg-[var(--hunk-header-bg)]" {@attach view.selectable(hunk, hunkIndex, line, lineIndex)}>
192191
<div class="line-number h-full px-2 select-none {lineType.lineNoClasses}">{getDisplayLineNo(line, line.oldLineNo)}</div>
193192
</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>
193+
<div class="bg-[var(--hunk-header-bg)]" {@attach view.selectable(hunk, hunkIndex, line, lineIndex)}>
194+
<div
195+
class="selected-indicator line-number h-full px-2 select-none {lineType.lineNoClasses}"
196+
data-selected={boolAttr(view.isSelected(hunkIndex, lineIndex))}
197+
>
198+
{getDisplayLineNo(line, line.newLineNo)}
199+
</div>
196200
</div>
197-
<div class="w-full pl-[1rem] {lineType.classes}">
201+
<div
202+
class="selected-indicator w-full pl-[1rem] {lineType.classes}"
203+
data-selection-start={boolAttr(view.isSelectionStart(hunkIndex, lineIndex))}
204+
data-selection-end={boolAttr(view.isSelectionEnd(hunkIndex, lineIndex))}
205+
{@attach (element) => {
206+
onMount(() => {
207+
if (jumpToSelection && selection && selection.hunk === hunkIndex && selectionMidpoint === lineIndex) {
208+
jumpToSelection = false;
209+
// Need to schedule because otherwise the vlist rendering surrounding elements may shift things
210+
// and cause the element to scroll to the wrong position
211+
// This is not 100% reliable but is good enough for now
212+
const scheduledJump = setTimeout(() => {
213+
element.scrollIntoView({ block: "center", inline: "center" });
214+
}, 200);
215+
return () => {
216+
if (scheduledJump) {
217+
clearTimeout(scheduledJump);
218+
}
219+
};
220+
}
221+
});
222+
}}
223+
>
198224
{@render lineContentWrapper(line, hunkIndex, lineIndex, lineType, innerPatchLineTypeProps[line.innerPatchLineType])}
199225
</div>
200226
{/snippet}
@@ -209,7 +235,7 @@
209235
>
210236
{#each diffViewerPatch.hunks as hunk, hunkIndex (hunkIndex)}
211237
{#each hunk.lines as line, lineIndex (lineIndex)}
212-
{@render renderLine(line, hunkIndex, lineIndex)}
238+
{@render renderLine(line, hunk, hunkIndex, lineIndex)}
213239
{/each}
214240
{/each}
215241
</div>
@@ -266,4 +292,19 @@
266292
left: -0.75rem;
267293
top: 0;
268294
}
295+
296+
.selected-indicator[data-selected] {
297+
box-shadow: inset -4px 0 0 0 var(--hunk-header-fg);
298+
}
299+
.selected-indicator[data-selection-start] {
300+
box-shadow: inset 0 1px 0 0 var(--hunk-header-fg);
301+
}
302+
.selected-indicator[data-selection-end] {
303+
box-shadow: inset 0 -1px 0 0 var(--hunk-header-fg);
304+
}
305+
.selected-indicator[data-selection-start][data-selection-end] {
306+
box-shadow:
307+
inset 0 1px 0 0 var(--hunk-header-fg),
308+
inset 0 -1px 0 0 var(--hunk-header-fg);
309+
}
269310
</style>

0 commit comments

Comments
 (0)