Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0e831cb
Allow selecting patch line (ranges) and linking to them
jpenilla Nov 23, 2025
b771411
This doesn't need to be async
jpenilla Nov 23, 2025
5b69246
Fix typo and cleanup parseLineRef
jpenilla Nov 23, 2025
035defe
Initial drag selection impl
jpenilla Nov 24, 2025
689563d
Adjust shift click selection to work with an anchor like dragging
jpenilla Nov 24, 2025
64a3157
Merge remote-tracking branch 'origin/master' into feat/line-selection…
jpenilla Nov 24, 2025
57420e5
Estimate patch height before fully loaded to reduce layout shift and
jpenilla Nov 27, 2025
c9ebf4d
Comment out jump to line delays for now
jpenilla Nov 27, 2025
3a107bd
Handle selection & scroll pos in popstate navigation
jpenilla Nov 27, 2025
a28dea6
Add missing state param and todo
jpenilla Nov 27, 2025
92665c3
Don't create history entries when new selection matches old
jpenilla Nov 27, 2025
0bf0552
Fix history spam when loading diff from url (with selection)
jpenilla Nov 27, 2025
9748cec
Fix open diff dialog opening when loading gh diff from url
jpenilla Nov 27, 2025
fd33e64
cleanup navigation logic on loading new diffs
jpenilla Nov 28, 2025
90c034b
Fix selection being lost from URL on load
jpenilla Nov 28, 2025
2710137
Disable Go to Selection when there is no selection
jpenilla Nov 28, 2025
4cccb2b
Add indicator for when file/lines in file are selected to file tree
jpenilla Nov 28, 2025
1ec5f28
Fix FileHeader focus/selected styles overriding --tw-shadow
jpenilla Nov 29, 2025
218a58e
Set cursor-pointer on selectable lines
jpenilla Nov 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 42 additions & 88 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions web-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.1",
"@types/bun": "^1.3.2",
"@types/bun": "^1.3.3",
"@types/webextension-polyfill": "^0.12.4",
"chrome-types": "^0.1.387",
"chrome-types": "^0.1.390",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2"
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
Expand Down
16 changes: 8 additions & 8 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.1",
"@iconify-json/octicon": "^1.2.17",
"@iconify-json/octicon": "^1.2.19",
"@iconify/tailwind4": "^1.1.0",
"@octokit/openapi-types": "^27.0.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-cloudflare": "^7.2.4",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/kit": "^2.49.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/chroma-js": "^3.1.2",
Expand All @@ -37,18 +37,18 @@
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.4",
"svelte": "^5.43.14",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.3.3",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2",
"vitest": "^4.0.8"
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4",
"vitest": "^4.0.13"
},
"dependencies": {
"bits-ui": "^2.14.2",
"bits-ui": "^2.14.4",
"chroma-js": "^3.1.2",
"diff": "^8.0.2",
"luxon": "^3.7.2",
Expand Down
14 changes: 13 additions & 1 deletion web/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ declare global {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
interface PageState {
/**
* whether this state entry loaded patches. when true, selection is likely to be unresolved,
* and scrollOffset is likely to be 0.
*/
initialLoad?: boolean;
scrollOffset?: number;
selection?: {
fileIdx: number;
lines?: LineSelection;
unresolvedLines?: UnresolvedLineSelection;
};
}
// interface Platform {}
}
}
Expand Down
159 changes: 117 additions & 42 deletions web/src/lib/components/diff/ConciseDiffView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
type ConciseDiffViewProps,
ConciseDiffViewState,
type DiffViewerPatchHunk,
innerPatchLineTypeProps,
type InnerPatchLineTypeProps,
makeSearchSegments,
Expand All @@ -13,9 +14,9 @@
type SearchSegment,
} from "$lib/components/diff/concise-diff-view.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import { onDestroy } from "svelte";
import { type MutableValue } from "$lib/util";
import { box } from "svelte-toolbelt";
import { boolAttr } from "runed";

let {
rawPatchContent,
Expand All @@ -28,10 +29,16 @@
searchQuery,
searchMatchingLines,
activeSearchResult = -1,
jumpToSearchResult = $bindable(false),
cache,
cacheKey,
unresolvedSelection,
selection = $bindable(),
jumpToSelection = $bindable(false),
}: ConciseDiffViewProps<K> = $props();

const uid = $props.id();

const parsedPatch = $derived.by(() => {
if (rawPatchContent !== undefined) {
return parseSinglePatch(rawPatchContent);
Expand All @@ -42,12 +49,20 @@
});

const view = new ConciseDiffViewState({
rootElementId: uid,

patch: box.with(() => parsedPatch),
syntaxHighlighting: box.with(() => syntaxHighlighting),
syntaxHighlightingTheme: box.with(() => syntaxHighlightingTheme),
omitPatchHeaderOnlyHunks: box.with(() => omitPatchHeaderOnlyHunks),
wordDiffs: box.with(() => wordDiffs),

unresolvedSelection: box.with(() => unresolvedSelection),
selection: box.with(
() => selection,
(v) => (selection = v),
),

cache: box.with(() => cache),
cacheKey: box.with(() => cacheKey),
});
Expand All @@ -60,39 +75,6 @@
}
}

let searchResultElements: HTMLSpanElement[] = $state([]);
let didInitialJump = $state(false);
let scheduledJump: ReturnType<typeof setTimeout> | undefined = undefined;
$effect(() => {
if (didInitialJump) {
return;
}
if (activeSearchResult >= 0 && searchResultElements[activeSearchResult] !== undefined) {
const element = searchResultElements[activeSearchResult];
const anchorElement = element.closest("tr");
// This is an exceptionally stupid and unreliable hack, but at least
// jumping to a result in a not-yet-loaded file works most of the time with a delay
// instead of never.
scheduledJump = setTimeout(() => {
if (scheduledJump !== undefined) {
clearTimeout(scheduledJump);
scheduledJump = undefined;
}

if (anchorElement !== null) {
anchorElement.scrollIntoView({ block: "center", inline: "center" });
}
}, 200);
didInitialJump = true;
}
});
onDestroy(() => {
if (scheduledJump !== undefined) {
clearTimeout(scheduledJump);
scheduledJump = undefined;
}
});

let searchSegments: Promise<SearchSegment[][][]> = $derived.by(async () => {
if (!searchQuery || !searchMatchingLines) {
return [];
Expand Down Expand Up @@ -134,6 +116,21 @@
}
return segments;
});

let selectionMidpoint = $derived.by(() => {
if (!selection) return null;
const startIdx = selection.start.idx;
const endIdx = selection.end.idx;
return Math.floor((startIdx + endIdx) / 2);
});

let heightEstimateRem = $derived.by(() => {
if (!parsedPatch) return 1.25;
const rawLineCount = parsedPatch.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0);
const headerAndSpacerLines = parsedPatch.hunks.length * 2;
const totalLines = rawLineCount + headerAndSpacerLines;
return totalLines * 1.25;
});
</script>

{#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)}
Expand Down Expand Up @@ -165,7 +162,21 @@
<span class="inline leading-[0.875rem]">
{#each lineSearchSegments as searchSegment, index (index)}
{#if searchSegment.highlighted}<span
bind:this={searchResultElements[searchSegment.id ?? -1]}
{@attach (element) => {
if (jumpToSearchResult && searchSegment.id === activeSearchResult) {
element.scrollIntoView({ block: "center", inline: "center" });
jumpToSearchResult = false;
// See similar code & comment below around jumping to selections
//const scheduledJump = setTimeout(() => {
// jumpToSearchResult = false;
// element.scrollIntoView({ block: "center", inline: "center" });
//}, 200);
//return () => {
// jumpToSearchResult = false;
// clearTimeout(scheduledJump);
//};
}
}}
class={{
"bg-[#d4a72c66]": searchSegment.id !== activeSearchResult,
"bg-[#ff9632]": searchSegment.id === activeSearchResult,
Expand All @@ -186,30 +197,79 @@
{/await}
{/snippet}

{#snippet renderLine(line: PatchLine, hunkIndex: number, lineIndex: number)}
{#snippet renderLine(line: PatchLine, hunk: DiffViewerPatchHunk, hunkIndex: number, lineIndex: number)}
{@const lineType = patchLineTypeProps[line.type]}
<div class="bg-[var(--hunk-header-bg)]">
{@const lineTypeSelectable = line.type !== PatchLineType.HEADER && line.type !== PatchLineType.SPACER}
<div
class="bg-[var(--hunk-header-bg)] data-selectable:cursor-pointer"
data-hunk-idx={hunkIndex}
data-line-idx={lineIndex}
data-selectable={boolAttr(lineTypeSelectable)}
{@attach view.selectable(hunk, hunkIndex, line, lineIndex)}
>
<div class="line-number h-full px-2 select-none {lineType.lineNoClasses}">{getDisplayLineNo(line, line.oldLineNo)}</div>
</div>
<div class="bg-[var(--hunk-header-bg)]">
<div class="line-number h-full px-2 select-none {lineType.lineNoClasses}">{getDisplayLineNo(line, line.newLineNo)}</div>
<div
class="bg-[var(--hunk-header-bg)] data-selectable:cursor-pointer"
data-hunk-idx={hunkIndex}
data-line-idx={lineIndex}
data-selectable={boolAttr(lineTypeSelectable)}
{@attach view.selectable(hunk, hunkIndex, line, lineIndex)}
>
<div
class="selected-indicator line-number h-full px-2 select-none {lineType.lineNoClasses}"
data-selected={boolAttr(view.isSelected(hunkIndex, lineIndex))}
>
{getDisplayLineNo(line, line.newLineNo)}
</div>
</div>
<div class="w-full pl-[1rem] {lineType.classes}">
<div
class="selected-indicator w-full pl-[1rem] {lineType.classes}"
data-hunk-idx={hunkIndex}
data-line-idx={lineIndex}
data-selection-start={boolAttr(view.isSelectionStart(hunkIndex, lineIndex))}
data-selection-end={boolAttr(view.isSelectionEnd(hunkIndex, lineIndex))}
{@attach (element) => {
if (jumpToSelection && selection && selection.hunk === hunkIndex && selectionMidpoint === lineIndex) {
element.scrollIntoView({ block: "center", inline: "center" });
jumpToSelection = false;
// Need to schedule because otherwise the vlist rendering surrounding elements may shift things
// and cause the element to scroll to the wrong position
// This is not 100% reliable but is good enough for now
//const scheduledJump = setTimeout(() => {
// jumpToSelection = false;
// element.scrollIntoView({ block: "center", inline: "center" });
//}, 200);
//return () => {
// if (scheduledJump) {
// jumpToSelection = false;
// clearTimeout(scheduledJump);
// }
//};
}
}}
>
{@render lineContentWrapper(line, hunkIndex, lineIndex, lineType, innerPatchLineTypeProps[line.innerPatchLineType])}
</div>
{/snippet}

{#await Promise.all([view.rootStyle, view.diffViewerPatch])}
<div class="flex items-center justify-center bg-neutral-2 p-4"><Spinner /></div>
<div class="relative bg-neutral-2" style="min-height: {heightEstimateRem}rem;">
<!-- 2.25 rem for file header offset -->
<div class="sticky top-[2.25rem] flex items-center justify-center p-4">
<Spinner />
</div>
</div>
{:then [rootStyle, diffViewerPatch]}
<div
id={uid}
style={rootStyle}
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)]"
data-wrap={lineWrap}
>
{#each diffViewerPatch.hunks as hunk, hunkIndex (hunkIndex)}
{#each hunk.lines as line, lineIndex (lineIndex)}
{@render renderLine(line, hunkIndex, lineIndex)}
{@render renderLine(line, hunk, hunkIndex, lineIndex)}
{/each}
{/each}
</div>
Expand Down Expand Up @@ -266,4 +326,19 @@
left: -0.75rem;
top: 0;
}

.selected-indicator[data-selected] {
box-shadow: inset -4px 0 0 0 var(--hunk-header-fg);
}
.selected-indicator[data-selection-start] {
box-shadow: inset 0 1px 0 0 var(--hunk-header-fg);
}
.selected-indicator[data-selection-end] {
box-shadow: inset 0 -1px 0 0 var(--hunk-header-fg);
}
.selected-indicator[data-selection-start][data-selection-end] {
box-shadow:
inset 0 1px 0 0 var(--hunk-header-fg),
inset 0 -1px 0 0 var(--hunk-header-fg);
}
</style>
Loading