From 0e831cbd762f1bd4729296b1ed031dc40df3e9b3 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:03:59 -0700 Subject: [PATCH 01/18] Allow selecting patch line (ranges) and linking to them --- .../components/diff/ConciseDiffView.svelte | 123 +++++--- .../diff/concise-diff-view.svelte.ts | 270 +++++++++++++++++- .../settings/ShikiThemeSelector.svelte | 4 +- web/src/lib/diff-viewer.svelte.ts | 150 +++++++++- web/src/lib/open-diff-dialog.svelte.ts | 14 +- web/src/lib/util.ts | 16 +- web/src/routes/DiffWrapper.svelte | 13 + web/src/routes/FileHeader.svelte | 9 +- web/src/routes/Sidebar.svelte | 45 +-- 9 files changed, 541 insertions(+), 103 deletions(-) diff --git a/web/src/lib/components/diff/ConciseDiffView.svelte b/web/src/lib/components/diff/ConciseDiffView.svelte index bd1724c..d317491 100644 --- a/web/src/lib/components/diff/ConciseDiffView.svelte +++ b/web/src/lib/components/diff/ConciseDiffView.svelte @@ -2,6 +2,7 @@ import { type ConciseDiffViewProps, ConciseDiffViewState, + type DiffViewerPatchHunk, innerPatchLineTypeProps, type InnerPatchLineTypeProps, makeSearchSegments, @@ -13,9 +14,10 @@ type SearchSegment, } from "$lib/components/diff/concise-diff-view.svelte"; import Spinner from "$lib/components/Spinner.svelte"; - import { onDestroy } from "svelte"; + import { onMount } from "svelte"; import { type MutableValue } from "$lib/util"; import { box } from "svelte-toolbelt"; + import { boolAttr } from "runed"; let { rawPatchContent, @@ -28,8 +30,12 @@ searchQuery, searchMatchingLines, activeSearchResult = -1, + jumpToSearchResult = $bindable(false), cache, cacheKey, + unresolvedSelection, + selection = $bindable(), + jumpToSelection = $bindable(false), }: ConciseDiffViewProps = $props(); const parsedPatch = $derived.by(() => { @@ -48,6 +54,12 @@ 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), }); @@ -60,39 +72,6 @@ } } - let searchResultElements: HTMLSpanElement[] = $state([]); - let didInitialJump = $state(false); - let scheduledJump: ReturnType | 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 = $derived.by(async () => { if (!searchQuery || !searchMatchingLines) { return []; @@ -134,6 +113,13 @@ } 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); + }); {#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)} @@ -165,7 +151,20 @@ {#each lineSearchSegments as searchSegment, index (index)} {#if searchSegment.highlighted} { + onMount(() => { + if (jumpToSearchResult && searchSegment.id === activeSearchResult) { + jumpToSearchResult = false; + // See similar code & comment below around jumping to selections + const scheduledJump = setTimeout(() => { + element.scrollIntoView({ block: "center", inline: "center" }); + }, 100); + return () => { + clearTimeout(scheduledJump); + }; + } + }); + }} class={{ "bg-[#d4a72c66]": searchSegment.id !== activeSearchResult, "bg-[#ff9632]": searchSegment.id === activeSearchResult, @@ -186,15 +185,42 @@ {/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]} -
+
{getDisplayLineNo(line, line.oldLineNo)}
-
-
{getDisplayLineNo(line, line.newLineNo)}
+
+
+ {getDisplayLineNo(line, line.newLineNo)} +
-
+
{ + onMount(() => { + if (jumpToSelection && selection && selection.hunk === hunkIndex && selectionMidpoint === lineIndex) { + 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(() => { + element.scrollIntoView({ block: "center", inline: "center" }); + }, 200); + return () => { + if (scheduledJump) { + clearTimeout(scheduledJump); + } + }; + } + }); + }} + > {@render lineContentWrapper(line, hunkIndex, lineIndex, lineType, innerPatchLineTypeProps[line.innerPatchLineType])}
{/snippet} @@ -209,7 +235,7 @@ > {#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}
@@ -266,4 +292,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); + } diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index c44f2cc..903aeec 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -16,32 +16,120 @@ import chroma from "chroma-js"; import { getEffectiveGlobalTheme } from "$lib/theme.svelte"; import { onDestroy } from "svelte"; import { DEFAULT_THEME_LIGHT } from "$lib/global-options.svelte"; +import type { WritableBoxedValues } from "svelte-toolbelt"; +import type { Attachment } from "svelte/attachments"; +import { on } from "svelte/events"; + +export interface UnresolvedLineRef { + /** + * line number in the patch data + */ + no: number; + /** + * - true: added or context line + * - false: removed line + */ + new: boolean; +} + +export interface LineRef extends UnresolvedLineRef { + /** + * line index in the diff viewer patch hunk + */ + idx: number; +} + +export interface HunkIndexedLineRef extends LineRef { + /** + * hunk index in the diff viewer patch + */ + hunkIdx: number; +} + +export interface LineSelection { + /** + * hunk index in the diff viewer patch + */ + hunk: number; + start: LineRef; + end: LineRef; +} + +export interface UnresolvedLineSelection { + start: UnresolvedLineRef; + end: UnresolvedLineRef; +} + +export function writeLineRef(ref: LineRef): string { + const prefix = ref.new ? "R" : "L"; + return prefix + ref.no.toString(); +} -export type DiffViewerPatch = { +export function parseLineRef(string: string): UnresolvedLineRef | null { + if (string.length < 2) { + return null; + } + const prefix = string.substring(0, 1).toUpperCase(); + if (prefix !== "R" && prefix !== "L") { + return null; + } + const isNew = string.startsWith("R"); + const numberString = string.substring(1); + try { + const number = parseInt(numberString); + if (!Number.isFinite(number)) { + return null; + } + return { no: number, new: isNew }; + } catch { + return null; + } +} + +export function resolveLineRef(ref: UnresolvedLineRef, hunks: DiffViewerPatchHunk[]): HunkIndexedLineRef | null { + for (let i = 0; i < hunks.length; i++) { + const hunk = hunks[i]; + for (let j = 0; j < hunk.lines.length; j++) { + const line = hunk.lines[j]; + if (line.type === PatchLineType.HEADER || line.type === PatchLineType.SPACER) { + continue; + } + if (line.oldLineNo === ref.no && !ref.new) { + return { hunkIdx: i, idx: j, no: line.oldLineNo, new: false }; + } + if (line.newLineNo === ref.no && ref.new) { + return { hunkIdx: i, idx: j, no: line.newLineNo, new: true }; + } + } + } + return null; +} + +export interface DiffViewerPatch { hunks: DiffViewerPatchHunk[]; -}; +} -export type DiffViewerPatchHunk = { +export interface DiffViewerPatchHunk { lines: PatchLine[]; innerPatchHeaderChangesOnly?: boolean; -}; +} -export type PatchLine = { +export interface PatchLine { type: PatchLineType; content: LineSegment[]; lineBreak?: boolean; innerPatchLineType: InnerPatchLineType; oldLineNo?: number; newLineNo?: number; -}; +} -export type LineSegment = { +export interface LineSegment { text?: string | null; iconClass?: string | null; caption?: string | null; classes?: string; style?: string; -}; +} export enum PatchLineType { HEADER, @@ -57,11 +145,11 @@ export enum InnerPatchLineType { NONE, } -export type PatchLineTypeProps = { +export interface PatchLineTypeProps { classes: string; lineNoClasses: string; prefix: string; -}; +} export const patchLineTypeProps: Record = { [PatchLineType.HEADER]: { @@ -91,9 +179,9 @@ export const patchLineTypeProps: Record = { }, }; -export type InnerPatchLineTypeProps = { +export interface InnerPatchLineTypeProps { style: string; -}; +} export const innerPatchLineTypeProps: Record = { [InnerPatchLineType.ADD]: { @@ -1035,9 +1123,14 @@ export interface ConciseDiffViewProps { searchQuery?: string; searchMatchingLines?: () => Promise; activeSearchResult?: number; + jumpToSearchResult?: boolean; cache?: Map; cacheKey?: K; + + unresolvedSelection?: UnresolvedLineSelection; + selection?: LineSelection; + jumpToSelection?: boolean; } export type ConciseDiffViewStateProps = ReadableBoxedValues<{ @@ -1050,7 +1143,12 @@ export type ConciseDiffViewStateProps = ReadableBoxedValues<{ cache: Map | undefined; cacheKey: K | undefined; -}>; + + unresolvedSelection: UnresolvedLineSelection | undefined; +}> & + WritableBoxedValues<{ + selection: LineSelection | undefined; + }>; export class ConciseDiffViewState { diffViewerPatch: Promise = $state(new Promise(() => [])); @@ -1109,9 +1207,10 @@ export class ConciseDiffViewState { ); this.cachedState = new ConciseDiffViewCachedState(promise, this.props); promise.then( - () => { + (patch) => { // Don't replace a potentially completed promise with a pending one, wait until the replacement is ready for smooth transitions this.diffViewerPatch = promise; + this.resolveOrUpdateSelection(patch); }, () => { // Propagate errors @@ -1120,10 +1219,153 @@ export class ConciseDiffViewState { ); } + private resolveOrUpdateSelection(patch: DiffViewerPatch) { + if (this.props.unresolvedSelection.current) { + const unresolved = this.props.unresolvedSelection.current; + const start = resolveLineRef(unresolved.start, patch.hunks); + const end = resolveLineRef(unresolved.end, patch.hunks); + if (start && end && start.hunkIdx === end.hunkIdx) { + this.props.selection.current = { + hunk: start.hunkIdx, + start: start, + end: end, + }; + } + } else if (this.props.selection.current) { + const current = this.props.selection.current; + const start = resolveLineRef(current.start, patch.hunks); + const end = resolveLineRef(current.end, patch.hunks); + if (start && end && start.hunkIdx === end.hunkIdx) { + this.props.selection.current = { + hunk: start.hunkIdx, + start: start, + end: end, + }; + } else { + this.props.selection.current = undefined; + } + } + } + restore(state: ConciseDiffViewCachedState) { this.diffViewerPatch = state.diffViewerPatch; this.cachedState = state; } + + selectable(hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number): Attachment { + return (element) => { + if (line.type === PatchLineType.SPACER || line.type === PatchLineType.HEADER) { + return; + } + + const destroyClick = on(element, "click", async (e) => { + this.updateSelection(hunk, hunkIdx, line, lineIdx, e.shiftKey); + }); + return () => { + destroyClick(); + }; + }; + } + + updateSelection(hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number, shift: boolean) { + const existingSelection = this.props.selection.current; + + const clicked: LineRef = { + idx: lineIdx, + no: line.newLineNo ?? line.oldLineNo!, + new: line.newLineNo !== undefined, + }; + + // New selection + if (!shift || existingSelection === undefined || existingSelection.hunk !== hunkIdx) { + this.props.selection.current = { + hunk: hunkIdx, + start: clicked, + end: clicked, + }; + return; + } + + // Shift click idx == start == end: clear selection + if (existingSelection.start.idx === existingSelection.end.idx && lineIdx === existingSelection.start.idx) { + this.props.selection.current = undefined; + return; + } + + // Shift click outside selection: expand selection + if (lineIdx < existingSelection.start.idx) { + this.props.selection.current = { + ...existingSelection, + start: clicked, + }; + return; + } + if (lineIdx > existingSelection.end.idx) { + this.props.selection.current = { + ...existingSelection, + end: clicked, + }; + return; + } + + // Shift click inside selection: shrink closest side (start/end) of selection to exclude clicked + const distToStart = lineIdx - existingSelection.start.idx; + const distToEnd = existingSelection.end.idx - lineIdx; + + if (distToStart <= distToEnd) { + // Shrink from start: move start to line after clicked + const newStartIdx = lineIdx + 1; + if (newStartIdx > existingSelection.end.idx) { + // Selection would be empty, clear it + this.props.selection.current = undefined; + return; + } + const newStartLine = hunk.lines[newStartIdx]; + this.props.selection.current = { + ...existingSelection, + start: { + idx: newStartIdx, + no: newStartLine.newLineNo ?? newStartLine.oldLineNo!, + new: newStartLine.newLineNo !== undefined, + }, + }; + } else { + // Shrink from end: move end to line before clicked + const newEndIdx = lineIdx - 1; + if (newEndIdx < existingSelection.start.idx) { + // Selection would be empty, clear it + this.props.selection.current = undefined; + return; + } + const newEndLine = hunk.lines[newEndIdx]; + this.props.selection.current = { + ...existingSelection, + end: { + idx: newEndIdx, + no: newEndLine.newLineNo ?? newEndLine.oldLineNo!, + new: newEndLine.newLineNo !== undefined, + }, + }; + } + } + + isSelected(hunkIdx: number, lineIdx: number): boolean { + const selection = this.props.selection.current; + if (selection === undefined || selection.hunk !== hunkIdx) return false; + return lineIdx >= selection.start.idx && lineIdx <= selection.end.idx; + } + + isSelectionStart(hunkIdx: number, lineIdx: number): boolean { + const selection = this.props.selection.current; + if (selection === undefined || selection.hunk !== hunkIdx) return false; + return lineIdx === selection.start.idx; + } + + isSelectionEnd(hunkIdx: number, lineIdx: number): boolean { + const selection = this.props.selection.current; + if (selection === undefined || selection.hunk !== hunkIdx) return false; + return lineIdx === selection.end.idx; + } } export type SearchSegment = { diff --git a/web/src/lib/components/settings/ShikiThemeSelector.svelte b/web/src/lib/components/settings/ShikiThemeSelector.svelte index 099b34c..bb0adf0 100644 --- a/web/src/lib/components/settings/ShikiThemeSelector.svelte +++ b/web/src/lib/components/settings/ShikiThemeSelector.svelte @@ -20,17 +20,17 @@ - {capitalizeFirstLetter(mode)} theme + {capitalizeFirstLetter(mode)} theme
(triggerLabelW = e[0].target.scrollWidth)} aria-label="Current {mode} syntax highlighting theme" class="scrolling-text grow text-left text-nowrap" style="--scroll-distance: -{scrollDistance}px;" + {@attach resizeObserver((e) => (triggerLabelW = e[0].target.scrollWidth))} > {value}
diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 7dfa5a2..2858203 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -9,9 +9,18 @@ import { parseMultiFilePatchGithub, } from "./github.svelte"; import { type StructuredPatch } from "diff"; -import { ConciseDiffViewCachedState, isNoNewlineAtEofLine, parseSinglePatch, patchHeaderDiffOnly } from "$lib/components/diff/concise-diff-view.svelte"; +import { + ConciseDiffViewCachedState, + isNoNewlineAtEofLine, + parseSinglePatch, + patchHeaderDiffOnly, + type LineSelection, + writeLineRef, + parseLineRef, + type UnresolvedLineSelection, +} from "$lib/components/diff/concise-diff-view.svelte"; import { countOccurrences, type FileTreeNodeData, makeFileTree, type LazyPromise, lazyPromise, animationFramePromise, yieldToBrowser } from "$lib/util"; -import { onDestroy, tick } from "svelte"; +import { onDestroy, onMount, tick } from "svelte"; import { type TreeNode, TreeState } from "$lib/components/tree/index.svelte"; import { VList } from "virtua/svelte"; import { Context, Debounced, watch } from "runed"; @@ -19,6 +28,8 @@ import { MediaQuery } from "svelte/reactivity"; import { ProgressBarState } from "$lib/components/progress-bar/index.svelte"; import { Keybinds } from "./keybinds.svelte"; import { LayoutState, type PersistentLayoutState } from "./layout.svelte"; +import { page } from "$app/state"; +import { goto } from "$app/navigation"; export const GITHUB_URL_PARAM = "github_url"; export const PATCH_URL_PARAM = "patch_url"; @@ -86,6 +97,46 @@ export interface FileState { collapsed: boolean; } +export interface Selection { + file: FileDetails; + lines?: LineSelection; + unresolvedLines?: UnresolvedLineSelection; +} + +function makeUrlHashValue(selection: Selection): string { + let hash = encodeURIComponent(selection.file.toFile); + if (selection.lines) { + hash += ":"; + hash += writeLineRef(selection.lines.start); + hash += ":"; + hash += writeLineRef(selection.lines.end); + } + return hash; +} + +interface UnresolvedSelection { + file: string; + lines?: UnresolvedLineSelection; +} + +function parseUrlHashValue(hash: string): UnresolvedSelection | null { + const parts = hash.split(":"); + if (parts.length === 1) { + return { + file: decodeURIComponent(parts[0]), + }; + } + if (parts.length !== 3) return null; + const file = decodeURIComponent(parts[0]); + const start = parseLineRef(parts[1]); + const end = parseLineRef(parts[2]); + if (!start || !end) return null; + return { + file, + lines: { start, end }, + }; +} + export interface ImageDiffDetails { fileA: LazyPromise | null; fileB: LazyPromise | null; @@ -186,12 +237,16 @@ export interface ViewerStatistics { fileRemovedLines: number[]; } -export interface GithubDiffMetadata { +export interface BaseDiffMetadata { + linkable: boolean; +} + +export interface GithubDiffMetadata extends BaseDiffMetadata { type: "github"; details: GithubDiff; } -export interface FileDiffMetadata { +export interface FileDiffMetadata extends BaseDiffMetadata { type: "file"; fileName: string; } @@ -213,12 +268,18 @@ export class MultiFileDiffViewerState { diffMetadata: DiffMetadata | null = $state(null); fileDetails: FileDetails[] = $state([]); // Read-only state fileStates: FileState[] = $state([]); // Mutable state - readonly stats: ViewerStatistics = $derived(this.countStats()); + stats: ViewerStatistics = $state({ + addedLines: 0, + removedLines: 0, + fileAddedLines: [], + fileRemovedLines: [], + }); // Content search state searchQuery: string = $state(""); readonly searchQueryDebounced = new Debounced(() => this.searchQuery, 500); readonly searchResults: Promise = $derived(this.findSearchResults()); + jumpToSearchResult: boolean = $state(false); // File tree state tree: TreeState | undefined = $state(); @@ -229,10 +290,15 @@ export class MultiFileDiffViewerState { this.fileTreeFilterDebounced.current ? this.fileDetails.filter((f) => this.filterFile(f)) : this.fileDetails, ); + // Selection state + urlSelection: UnresolvedSelection | undefined = $state(); + selection: Selection | undefined = $state(); + jumpToSelection: boolean = $state(false); + // Misc. component state diffViewCache: Map = new Map(); vlist: VList | undefined = $state(); - readonly loadingState: LoadingState = $state(new LoadingState()); + readonly loadingState = new LoadingState(); readonly layoutState; // Transient state @@ -246,6 +312,14 @@ export class MultiFileDiffViewerState { // Make sure to revoke object URLs when the component is destroyed onDestroy(() => this.clearImages()); + onMount(() => { + let hash = page.url.hash; + if (hash.startsWith("#")) hash = hash.substring(1); + if (hash) { + this.urlSelection = parseUrlHashValue(hash) ?? undefined; + } + }); + const keybinds = new Keybinds(); keybinds.registerModifierBind("o", () => this.openOpenDiffDialog()); keybinds.registerModifierBind(",", () => this.openSettingsDialog()); @@ -297,6 +371,29 @@ export class MultiFileDiffViewerState { } } + getSelection(file: FileDetails) { + if (this.selection?.file.index === file.index) { + return this.selection; + } + return null; + } + + setSelection(file: FileDetails, lines: LineSelection | undefined) { + this.selection = { file, lines }; + + goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { + keepFocus: true, + }); + } + + clearSelection() { + this.selection = undefined; + + goto(`?${page.url.searchParams}`, { + keepFocus: true, + }); + } + scrollToFile(index: number, options: { autoExpand?: boolean; smooth?: boolean; focus?: boolean } = {}) { if (!this.vlist) return; @@ -333,7 +430,10 @@ export class MultiFileDiffViewerState { requestAnimationFrame(() => { const fileElement = document.getElementById(`file-${fileIdx}`); const resultElement = fileElement?.querySelector(`[data-match-id='${idx}']`) as HTMLElement | null | undefined; - if (!resultElement) return; + if (!resultElement) { + this.jumpToSearchResult = true; + return; + } resultElement.scrollIntoView({ block: "center", inline: "center" }); }); } @@ -365,6 +465,8 @@ export class MultiFileDiffViewerState { private clear(clearMeta: boolean = true) { // Reset state + this.selection = undefined; + this.jumpToSelection = false; this.fileStates = []; if (clearMeta) { this.diffMetadata = null; @@ -437,17 +539,45 @@ export class MultiFileDiffViewerState { } tempDetails.sort(compareFileDetails); - this.fileDetails.push(...tempDetails); + const statesArray: FileState[] = []; for (let i = 0; i < tempDetails.length; i++) { const details = tempDetails[i]; details.index = i; const state = tempStates.get(details.fromFile); if (state) { - this.fileStates.push(state); + statesArray.push(state); } } + this.fileDetails = tempDetails; + this.fileStates = statesArray; + + if (this.urlSelection) { + const urlSelection = this.urlSelection; + this.urlSelection = undefined; + const file = this.fileDetails.find((f) => f.toFile === urlSelection.file); + if (file && this.diffMetadata.linkable) { + this.jumpToSelection = true; + this.selection = { + file, + unresolvedLines: urlSelection.lines, + }; + await goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { + keepFocus: true, + }); + this.scrollToFile(file.index, { + focus: urlSelection.lines === undefined, + }); + } else { + await goto(`?${page.url.searchParams}`, { + keepFocus: true, + }); + } + } + + this.stats = this.countStats(); + return true; } catch (e) { this.clear(); // Clear any partially loaded state @@ -467,7 +597,7 @@ export class MultiFileDiffViewerState { return await this.loadPatches( async () => { const result = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise; - return { type: "github", details: await result.info }; + return { linkable: true, type: "github", details: await result.info }; }, async () => { const result = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise; diff --git a/web/src/lib/open-diff-dialog.svelte.ts b/web/src/lib/open-diff-dialog.svelte.ts index 99043de..b492337 100644 --- a/web/src/lib/open-diff-dialog.svelte.ts +++ b/web/src/lib/open-diff-dialog.svelte.ts @@ -79,7 +79,7 @@ export class OpenDiffDialogState { this.props.open.current = false; const success = await this.viewer.loadPatches( async () => { - return { type: "file", fileName: `${fileAMeta.name}...${fileBMeta.name}.patch` }; + return { linkable: false, type: "file", fileName: `${fileAMeta.name}...${fileBMeta.name}.patch` }; }, async () => { const isImageDiff = isImageFile(fileAMeta.name) && isImageFile(fileBMeta.name); @@ -152,7 +152,7 @@ export class OpenDiffDialogState { this.props.open.current = false; const success = await this.viewer.loadPatches( async () => { - return { type: "file", fileName: `${dirA.fileName}...${dirB.fileName}.patch` }; + return { linkable: false, type: "file", fileName: `${dirA.fileName}...${dirB.fileName}.patch` }; }, async () => { return this.generateDirPatches(dirA, dirB); @@ -276,7 +276,7 @@ export class OpenDiffDialogState { this.props.open.current = false; const success = await this.viewer.loadPatches( async () => { - return { type: "file", fileName: meta.name }; + return { linkable: this.patchFile.mode === "url", type: "file", fileName: meta.name }; }, async () => { return parseMultiFilePatch(text, this.viewer.loadingState); @@ -328,6 +328,12 @@ export class OpenDiffDialogState { } else { newUrl.searchParams.delete(PATCH_URL_PARAM); } - await goto(`?${newUrl.searchParams}`); + let params = `?${newUrl.searchParams}`; + if (newUrl.hash) { + params += newUrl.hash; + } + await goto(params, { + keepFocus: true, + }); } } diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index ba1fb67..9ee88aa 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -3,9 +3,9 @@ import type { FileStatus } from "./github.svelte"; import type { TreeNode } from "$lib/components/tree/index.svelte"; import type { BundledLanguage, SpecialLanguage } from "shiki"; import { onMount } from "svelte"; -import type { Action } from "svelte/action"; import type { ReadableBox } from "svelte-toolbelt"; import { on } from "svelte/events"; +import { type Attachment } from "svelte/attachments"; export type Getter = () => T; @@ -463,15 +463,15 @@ export function watchLocalStorage(key: string, callback: (newValue: string | nul }); } -export const resizeObserver: Action = (node, callback) => { - const observer = new ResizeObserver(callback); - observer.observe(node); - return { - destroy() { +export function resizeObserver(callback: ResizeObserverCallback): Attachment { + return (element) => { + const observer = new ResizeObserver(callback); + observer.observe(element); + return () => { observer.disconnect(); - }, + }; }; -}; +} export function animationFramePromise() { return new Promise((resolve) => { diff --git a/web/src/routes/DiffWrapper.svelte b/web/src/routes/DiffWrapper.svelte index d6e337b..5bd04d3 100644 --- a/web/src/routes/DiffWrapper.svelte +++ b/web/src/routes/DiffWrapper.svelte @@ -66,8 +66,21 @@ searchQuery={viewer.searchQueryDebounced.current} searchMatchingLines={() => viewer.searchResults.then((r) => r.lines.get(value))} activeSearchResult={viewer.activeSearchResult && viewer.activeSearchResult.file === value ? viewer.activeSearchResult.idx : undefined} + bind:jumpToSearchResult={viewer.jumpToSearchResult} cache={viewer.diffViewCache} cacheKey={value} + unresolvedSelection={viewer.getSelection(value)?.unresolvedLines} + bind:selection={ + () => viewer.getSelection(value)?.lines, + (lines) => { + if (lines === undefined && viewer.selection?.file === value) { + viewer.clearSelection(); + } else { + viewer.setSelection(value, lines); + } + } + } + bind:jumpToSelection={viewer.jumpToSelection} />
{/if} diff --git a/web/src/routes/FileHeader.svelte b/web/src/routes/FileHeader.svelte index e59a3b4..17af420 100644 --- a/web/src/routes/FileHeader.svelte +++ b/web/src/routes/FileHeader.svelte @@ -43,6 +43,11 @@ } return { baseFileUrl: undefined, headFileUrl: undefined }; }); + + function selectHeader() { + viewer.scrollToFile(index, { autoExpand: false, smooth: true }); + viewer.setSelection(value, undefined); + } {#snippet fileName()} @@ -114,8 +119,8 @@ class="sticky top-0 z-10 flex flex-row items-center gap-2 border-b bg-neutral px-2 py-1 text-sm shadow-sm focus:ring-2 focus:ring-primary focus:outline-none focus:ring-inset" tabindex={0} role="button" - onclick={() => viewer.scrollToFile(index, { autoExpand: false, smooth: true })} - onkeyup={(event) => event.key === "Enter" && viewer.scrollToFile(index, { autoExpand: false, smooth: true })} + onclick={() => selectHeader()} + onkeyup={(event) => event.key === "Enter" && selectHeader()} > {#if value.type === "text"} diff --git a/web/src/routes/Sidebar.svelte b/web/src/routes/Sidebar.svelte index abba6df..79aab71 100644 --- a/web/src/routes/Sidebar.svelte +++ b/web/src/routes/Sidebar.svelte @@ -3,8 +3,8 @@ import { type FileDetails, getFileStatusProps, MultiFileDiffViewerState, staticSidebar } from "$lib/diff-viewer.svelte"; import Tree from "$lib/components/tree/Tree.svelte"; import { type TreeNode } from "$lib/components/tree/index.svelte"; - import { type Action } from "svelte/action"; import { on } from "svelte/events"; + import { type Attachment } from "svelte/attachments"; const viewer = MultiFileDiffViewerState.get(); @@ -20,30 +20,31 @@ } } - const focusFileDoubleClick: Action = (div, { index }) => { - const destroyDblclick = on(div, "dblclick", (event) => { - const element: HTMLElement = event.target as HTMLElement; - if (element.tagName.toLowerCase() !== "input") { - viewer.scrollToFile(index, { focus: true }); - if (!staticSidebar.current) { - viewer.layoutState.sidebarCollapsed = true; + function focusFileDoubleClick(value: FileDetails): Attachment { + return (div) => { + const destroyDblclick = on(div, "dblclick", (event) => { + const element: HTMLElement = event.target as HTMLElement; + if (element.tagName.toLowerCase() !== "input") { + viewer.scrollToFile(value.index, { focus: true }); + viewer.setSelection(value, undefined); + if (!staticSidebar.current) { + viewer.layoutState.sidebarCollapsed = true; + } } - } - }); - const destoryMousedown = on(div, "mousedown", (event) => { - const element: HTMLElement = event.target as HTMLElement; - if (element.tagName.toLowerCase() !== "input" && event.detail === 2) { - // Don't select text on double click - event.preventDefault(); - } - }); - return { - destroy() { + }); + const destoryMousedown = on(div, "mousedown", (event) => { + const element: HTMLElement = event.target as HTMLElement; + if (element.tagName.toLowerCase() !== "input" && event.detail === 2) { + // Don't select text on double click + event.preventDefault(); + } + }); + return () => { destroyDblclick(); destoryMousedown(); - }, + }; }; - }; + }
@@ -77,7 +78,7 @@
scrollToFileClick(e, value.index)} - use:focusFileDoubleClick={{ index: value.index }} + {@attach focusFileDoubleClick(value)} onkeydown={(e) => e.key === "Enter" && viewer.scrollToFile(value.index)} role="button" tabindex="0" From b771411566e13ba7acb30794a54bbe3b3f138ee8 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:13:44 -0700 Subject: [PATCH 02/18] This doesn't need to be async --- web/src/lib/components/diff/concise-diff-view.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index 903aeec..b5b8df5 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -1258,7 +1258,7 @@ export class ConciseDiffViewState { return; } - const destroyClick = on(element, "click", async (e) => { + const destroyClick = on(element, "click", (e) => { this.updateSelection(hunk, hunkIdx, line, lineIdx, e.shiftKey); }); return () => { From 5b69246ffe8fedbd197c3e6f268bc26145b77c68 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sun, 23 Nov 2025 12:31:25 -0700 Subject: [PATCH 03/18] Fix typo and cleanup parseLineRef --- .../lib/components/diff/concise-diff-view.svelte.ts | 12 ++++-------- web/src/routes/Sidebar.svelte | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index b5b8df5..ed08ea8 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -73,17 +73,13 @@ export function parseLineRef(string: string): UnresolvedLineRef | null { if (prefix !== "R" && prefix !== "L") { return null; } - const isNew = string.startsWith("R"); + const isNew = prefix === "R"; const numberString = string.substring(1); - try { - const number = parseInt(numberString); - if (!Number.isFinite(number)) { - return null; - } - return { no: number, new: isNew }; - } catch { + const number = Number.parseInt(numberString); + if (!Number.isFinite(number)) { return null; } + return { no: number, new: isNew }; } export function resolveLineRef(ref: UnresolvedLineRef, hunks: DiffViewerPatchHunk[]): HunkIndexedLineRef | null { diff --git a/web/src/routes/Sidebar.svelte b/web/src/routes/Sidebar.svelte index 79aab71..7505e0a 100644 --- a/web/src/routes/Sidebar.svelte +++ b/web/src/routes/Sidebar.svelte @@ -32,7 +32,7 @@ } } }); - const destoryMousedown = on(div, "mousedown", (event) => { + const destroyMousedown = on(div, "mousedown", (event) => { const element: HTMLElement = event.target as HTMLElement; if (element.tagName.toLowerCase() !== "input" && event.detail === 2) { // Don't select text on double click @@ -41,7 +41,7 @@ }); return () => { destroyDblclick(); - destoryMousedown(); + destroyMousedown(); }; }; } From 035defed55bc8eeb5b16981a2df2ec2415410a49 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:40:52 -0700 Subject: [PATCH 04/18] Initial drag selection impl --- .../components/diff/ConciseDiffView.svelte | 12 +- .../diff/concise-diff-view.svelte.ts | 158 ++++++++++++++---- 2 files changed, 133 insertions(+), 37 deletions(-) diff --git a/web/src/lib/components/diff/ConciseDiffView.svelte b/web/src/lib/components/diff/ConciseDiffView.svelte index d317491..f2d70cf 100644 --- a/web/src/lib/components/diff/ConciseDiffView.svelte +++ b/web/src/lib/components/diff/ConciseDiffView.svelte @@ -38,6 +38,8 @@ jumpToSelection = $bindable(false), }: ConciseDiffViewProps = $props(); + const uid = $props.id(); + const parsedPatch = $derived.by(() => { if (rawPatchContent !== undefined) { return parseSinglePatch(rawPatchContent); @@ -48,6 +50,8 @@ }); const view = new ConciseDiffViewState({ + rootElementId: uid, + patch: box.with(() => parsedPatch), syntaxHighlighting: box.with(() => syntaxHighlighting), syntaxHighlightingTheme: box.with(() => syntaxHighlightingTheme), @@ -187,10 +191,10 @@ {#snippet renderLine(line: PatchLine, hunk: DiffViewerPatchHunk, hunkIndex: number, lineIndex: number)} {@const lineType = patchLineTypeProps[line.type]} -
+
{getDisplayLineNo(line, line.oldLineNo)}
-
+
{ @@ -229,6 +235,7 @@
{:then [rootStyle, diffViewerPatch]}
{ jumpToSelection?: boolean; } -export type ConciseDiffViewStateProps = ReadableBoxedValues<{ +export type ConciseDiffViewStateProps = { + rootElementId: string; +} & ReadableBoxedValues<{ patch: StructuredPatch; syntaxHighlighting: boolean; @@ -1153,6 +1155,10 @@ export class ConciseDiffViewState { private readonly props: ConciseDiffViewStateProps; + // Drag selection state + private dragState: { hunk: DiffViewerPatchHunk; hunkIdx: number; line: PatchLine; lineIdx: number; didMove: boolean } | null = null; + private suppressNextClick = false; + constructor(props: ConciseDiffViewStateProps) { this.props = props; @@ -1255,34 +1261,134 @@ export class ConciseDiffViewState { } const destroyClick = on(element, "click", (e) => { + // Only handle click if we didn't just finish dragging + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } this.updateSelection(hunk, hunkIdx, line, lineIdx, e.shiftKey); }); + + const destroyPointerDown = on(element, "pointerdown", (e: PointerEvent) => { + // Only start drag on left click without shift key + if (e.button === 0 && !e.shiftKey) { + this.startDrag(element, e.pointerId, hunk, hunkIdx, line, lineIdx); + } + }); + return () => { destroyClick(); + destroyPointerDown(); }; }; } - updateSelection(hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number, shift: boolean) { - const existingSelection = this.props.selection.current; + private startDrag(element: HTMLElement, pointerId: number, hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number) { + this.dragState = { hunk, hunkIdx, line, lineIdx, didMove: false }; + + // Set initial selection + this.props.selection.current = { + hunk: hunkIdx, + start: this.createLineRef(line, lineIdx), + end: this.createLineRef(line, lineIdx), + }; + + // Capture pointer events to this element + element.setPointerCapture(pointerId); + + const abortController = new AbortController(); + const { signal } = abortController; + + on( + element, + "pointermove", + (e: PointerEvent) => { + if (!this.dragState) return; + + // Get the root element for this diff view + const rootElement = document.getElementById(this.props.rootElementId); + if (!rootElement) return; - const clicked: LineRef = { + // Get the element at the pointer position + const elementAtPoint = document.elementFromPoint(e.clientX, e.clientY); + if (!elementAtPoint) return; + + // Only process if the element is within this diff view's root element + if (!rootElement.contains(elementAtPoint)) return; + + const lineElement = elementAtPoint.closest("[data-hunk-idx][data-line-idx]") as HTMLElement | null; + if (!lineElement) return; + + const currentHunkIdx = Number(lineElement.dataset.hunkIdx); + const currentLineIdx = Number(lineElement.dataset.lineIdx); + + // Only allow dragging within the same hunk + if (currentHunkIdx !== this.dragState.hunkIdx || !Number.isFinite(currentHunkIdx) || !Number.isFinite(currentLineIdx)) { + return; + } + + if (this.dragState) { + this.dragState.didMove = true; + } + this.updateDragSelection(currentLineIdx); + }, + { signal }, + ); + + const onDragEnd = (e: PointerEvent) => { + element.releasePointerCapture(e.pointerId); + abortController.abort(); + + // Suppress the click event only if we actually moved during the drag + if (this.dragState?.didMove) { + this.suppressNextClick = true; + } + this.dragState = null; + }; + + on(element, "pointerup", onDragEnd, { signal }); + on(element, "pointercancel", onDragEnd, { signal }); + } + + private createLineRef(line: PatchLine, lineIdx: number): LineRef { + return { idx: lineIdx, no: line.newLineNo ?? line.oldLineNo!, new: line.newLineNo !== undefined, }; + } - // New selection - if (!shift || existingSelection === undefined || existingSelection.hunk !== hunkIdx) { - this.props.selection.current = { - hunk: hunkIdx, - start: clicked, - end: clicked, - }; + private updateDragSelection(currentLineIdx: number) { + if (!this.dragState) return; + + const { hunk, hunkIdx, lineIdx: startIdx } = this.dragState; + const currentLine = hunk.lines[currentLineIdx]; + + if (currentLine.type === PatchLineType.SPACER || currentLine.type === PatchLineType.HEADER) { + return; + } + + const minIdx = Math.min(startIdx, currentLineIdx); + const maxIdx = Math.max(startIdx, currentLineIdx); + + this.props.selection.current = { + hunk: hunkIdx, + start: this.createLineRef(hunk.lines[minIdx], minIdx), + end: this.createLineRef(hunk.lines[maxIdx], maxIdx), + }; + } + + updateSelection(hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number, shift: boolean) { + const existingSelection = this.props.selection.current; + const clicked = this.createLineRef(line, lineIdx); + + // New selection (no shift or different hunk) + if (!shift || !existingSelection || existingSelection.hunk !== hunkIdx) { + this.props.selection.current = { hunk: hunkIdx, start: clicked, end: clicked }; return; } - // Shift click idx == start == end: clear selection + // Shift click on single-line selection: clear selection if (existingSelection.start.idx === existingSelection.end.idx && lineIdx === existingSelection.start.idx) { this.props.selection.current = undefined; return; @@ -1290,21 +1396,15 @@ export class ConciseDiffViewState { // Shift click outside selection: expand selection if (lineIdx < existingSelection.start.idx) { - this.props.selection.current = { - ...existingSelection, - start: clicked, - }; + this.props.selection.current = { ...existingSelection, start: clicked }; return; } if (lineIdx > existingSelection.end.idx) { - this.props.selection.current = { - ...existingSelection, - end: clicked, - }; + this.props.selection.current = { ...existingSelection, end: clicked }; return; } - // Shift click inside selection: shrink closest side (start/end) of selection to exclude clicked + // Shift click inside selection: shrink closest side const distToStart = lineIdx - existingSelection.start.idx; const distToEnd = existingSelection.end.idx - lineIdx; @@ -1312,35 +1412,23 @@ export class ConciseDiffViewState { // Shrink from start: move start to line after clicked const newStartIdx = lineIdx + 1; if (newStartIdx > existingSelection.end.idx) { - // Selection would be empty, clear it this.props.selection.current = undefined; return; } - const newStartLine = hunk.lines[newStartIdx]; this.props.selection.current = { ...existingSelection, - start: { - idx: newStartIdx, - no: newStartLine.newLineNo ?? newStartLine.oldLineNo!, - new: newStartLine.newLineNo !== undefined, - }, + start: this.createLineRef(hunk.lines[newStartIdx], newStartIdx), }; } else { // Shrink from end: move end to line before clicked const newEndIdx = lineIdx - 1; if (newEndIdx < existingSelection.start.idx) { - // Selection would be empty, clear it this.props.selection.current = undefined; return; } - const newEndLine = hunk.lines[newEndIdx]; this.props.selection.current = { ...existingSelection, - end: { - idx: newEndIdx, - no: newEndLine.newLineNo ?? newEndLine.oldLineNo!, - new: newEndLine.newLineNo !== undefined, - }, + end: this.createLineRef(hunk.lines[newEndIdx], newEndIdx), }; } } From 689563dfa50f239cd84f42e1132a85151c86b345 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:50:10 -0700 Subject: [PATCH 05/18] Adjust shift click selection to work with an anchor like dragging --- .../diff/concise-diff-view.svelte.ts | 80 ++++++++----------- 1 file changed, 33 insertions(+), 47 deletions(-) diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index e479b5f..01fa423 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -1155,8 +1155,8 @@ export class ConciseDiffViewState { private readonly props: ConciseDiffViewStateProps; - // Drag selection state - private dragState: { hunk: DiffViewerPatchHunk; hunkIdx: number; line: PatchLine; lineIdx: number; didMove: boolean } | null = null; + private selectionAnchor: { hunkIdx: number; lineIdx: number } | null = null; + private dragSelectionState: { hunk: DiffViewerPatchHunk; didMove: boolean } | null = null; private suppressNextClick = false; constructor(props: ConciseDiffViewStateProps) { @@ -1284,7 +1284,8 @@ export class ConciseDiffViewState { } private startDrag(element: HTMLElement, pointerId: number, hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number) { - this.dragState = { hunk, hunkIdx, line, lineIdx, didMove: false }; + this.selectionAnchor = { hunkIdx, lineIdx }; + this.dragSelectionState = { hunk, didMove: false }; // Set initial selection this.props.selection.current = { @@ -1303,7 +1304,7 @@ export class ConciseDiffViewState { element, "pointermove", (e: PointerEvent) => { - if (!this.dragState) return; + if (!this.dragSelectionState || !this.selectionAnchor) return; // Get the root element for this diff view const rootElement = document.getElementById(this.props.rootElementId); @@ -1323,12 +1324,12 @@ export class ConciseDiffViewState { const currentLineIdx = Number(lineElement.dataset.lineIdx); // Only allow dragging within the same hunk - if (currentHunkIdx !== this.dragState.hunkIdx || !Number.isFinite(currentHunkIdx) || !Number.isFinite(currentLineIdx)) { + if (currentHunkIdx !== this.selectionAnchor.hunkIdx || !Number.isFinite(currentHunkIdx) || !Number.isFinite(currentLineIdx)) { return; } - if (this.dragState) { - this.dragState.didMove = true; + if (this.dragSelectionState) { + this.dragSelectionState.didMove = true; } this.updateDragSelection(currentLineIdx); }, @@ -1340,10 +1341,10 @@ export class ConciseDiffViewState { abortController.abort(); // Suppress the click event only if we actually moved during the drag - if (this.dragState?.didMove) { + if (this.dragSelectionState?.didMove) { this.suppressNextClick = true; } - this.dragState = null; + this.dragSelectionState = null; }; on(element, "pointerup", onDragEnd, { signal }); @@ -1359,17 +1360,18 @@ export class ConciseDiffViewState { } private updateDragSelection(currentLineIdx: number) { - if (!this.dragState) return; + if (!this.dragSelectionState || !this.selectionAnchor) return; - const { hunk, hunkIdx, lineIdx: startIdx } = this.dragState; + const { hunk } = this.dragSelectionState; + const { hunkIdx, lineIdx: anchorIdx } = this.selectionAnchor; const currentLine = hunk.lines[currentLineIdx]; if (currentLine.type === PatchLineType.SPACER || currentLine.type === PatchLineType.HEADER) { return; } - const minIdx = Math.min(startIdx, currentLineIdx); - const maxIdx = Math.max(startIdx, currentLineIdx); + const minIdx = Math.min(anchorIdx, currentLineIdx); + const maxIdx = Math.max(anchorIdx, currentLineIdx); this.props.selection.current = { hunk: hunkIdx, @@ -1385,52 +1387,36 @@ export class ConciseDiffViewState { // New selection (no shift or different hunk) if (!shift || !existingSelection || existingSelection.hunk !== hunkIdx) { this.props.selection.current = { hunk: hunkIdx, start: clicked, end: clicked }; + this.selectionAnchor = { hunkIdx, lineIdx }; return; } // Shift click on single-line selection: clear selection if (existingSelection.start.idx === existingSelection.end.idx && lineIdx === existingSelection.start.idx) { this.props.selection.current = undefined; + this.selectionAnchor = null; return; } - // Shift click outside selection: expand selection - if (lineIdx < existingSelection.start.idx) { - this.props.selection.current = { ...existingSelection, start: clicked }; - return; - } - if (lineIdx > existingSelection.end.idx) { - this.props.selection.current = { ...existingSelection, end: clicked }; - return; + // Determine anchor point: use existing anchor, or default to start of selection + let anchorIdx: number; + if (this.selectionAnchor && this.selectionAnchor.hunkIdx === hunkIdx) { + anchorIdx = this.selectionAnchor.lineIdx; + } else { + // No anchor or anchor is in different hunk, default to start of selection + anchorIdx = existingSelection.start.idx; + this.selectionAnchor = { hunkIdx, lineIdx: anchorIdx }; } - // Shift click inside selection: shrink closest side - const distToStart = lineIdx - existingSelection.start.idx; - const distToEnd = existingSelection.end.idx - lineIdx; + // Shift click: create selection from anchor to clicked line + const minIdx = Math.min(anchorIdx, lineIdx); + const maxIdx = Math.max(anchorIdx, lineIdx); - if (distToStart <= distToEnd) { - // Shrink from start: move start to line after clicked - const newStartIdx = lineIdx + 1; - if (newStartIdx > existingSelection.end.idx) { - this.props.selection.current = undefined; - return; - } - this.props.selection.current = { - ...existingSelection, - start: this.createLineRef(hunk.lines[newStartIdx], newStartIdx), - }; - } else { - // Shrink from end: move end to line before clicked - const newEndIdx = lineIdx - 1; - if (newEndIdx < existingSelection.start.idx) { - this.props.selection.current = undefined; - return; - } - this.props.selection.current = { - ...existingSelection, - end: this.createLineRef(hunk.lines[newEndIdx], newEndIdx), - }; - } + this.props.selection.current = { + hunk: hunkIdx, + start: this.createLineRef(hunk.lines[minIdx], minIdx), + end: this.createLineRef(hunk.lines[maxIdx], maxIdx), + }; } isSelected(hunkIdx: number, lineIdx: number): boolean { From 57420e57f155274d038018b61c9319b3e8db8f6d Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:07:05 -0700 Subject: [PATCH 06/18] Estimate patch height before fully loaded to reduce layout shift and improve linking consistency, add Go>Go to Selection menu action --- .../components/diff/ConciseDiffView.svelte | 69 ++++++++++--------- .../lib/components/menu-bar/MenuBar.svelte | 20 ++++++ web/src/lib/diff-viewer.svelte.ts | 8 ++- 3 files changed, 63 insertions(+), 34 deletions(-) diff --git a/web/src/lib/components/diff/ConciseDiffView.svelte b/web/src/lib/components/diff/ConciseDiffView.svelte index f2d70cf..383e13a 100644 --- a/web/src/lib/components/diff/ConciseDiffView.svelte +++ b/web/src/lib/components/diff/ConciseDiffView.svelte @@ -14,7 +14,6 @@ type SearchSegment, } from "$lib/components/diff/concise-diff-view.svelte"; import Spinner from "$lib/components/Spinner.svelte"; - import { onMount } from "svelte"; import { type MutableValue } from "$lib/util"; import { box } from "svelte-toolbelt"; import { boolAttr } from "runed"; @@ -124,6 +123,14 @@ 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; + }); {#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)} @@ -156,18 +163,16 @@ {#each lineSearchSegments as searchSegment, index (index)} {#if searchSegment.highlighted} { - onMount(() => { - if (jumpToSearchResult && searchSegment.id === activeSearchResult) { - jumpToSearchResult = false; - // See similar code & comment below around jumping to selections - const scheduledJump = setTimeout(() => { - element.scrollIntoView({ block: "center", inline: "center" }); - }, 100); - return () => { - clearTimeout(scheduledJump); - }; - } - }); + if (jumpToSearchResult && searchSegment.id === activeSearchResult) { + jumpToSearchResult = false; + // See similar code & comment below around jumping to selections + const scheduledJump = setTimeout(() => { + element.scrollIntoView({ block: "center", inline: "center" }); + }, 200); + return () => { + clearTimeout(scheduledJump); + }; + } }} class={{ "bg-[#d4a72c66]": searchSegment.id !== activeSearchResult, @@ -209,22 +214,20 @@ data-selection-start={boolAttr(view.isSelectionStart(hunkIndex, lineIndex))} data-selection-end={boolAttr(view.isSelectionEnd(hunkIndex, lineIndex))} {@attach (element) => { - onMount(() => { - if (jumpToSelection && selection && selection.hunk === hunkIndex && selectionMidpoint === lineIndex) { - 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(() => { - element.scrollIntoView({ block: "center", inline: "center" }); - }, 200); - return () => { - if (scheduledJump) { - clearTimeout(scheduledJump); - } - }; - } - }); + if (jumpToSelection && selection && selection.hunk === hunkIndex && selectionMidpoint === lineIndex) { + 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(() => { + element.scrollIntoView({ block: "center", inline: "center" }); + }, 200); + return () => { + if (scheduledJump) { + clearTimeout(scheduledJump); + } + }; + } }} > {@render lineContentWrapper(line, hunkIndex, lineIndex, lineType, innerPatchLineTypeProps[line.innerPatchLineType])} @@ -232,7 +235,12 @@ {/snippet} {#await Promise.all([view.rootStyle, view.diffViewerPatch])} -
+
+ +
+ +
+
{:then [rootStyle, diffViewerPatch]}
+ + Go + + + { + if (viewer.selection) { + viewer.scrollToFile(viewer.selection.file.index, { + focus: !viewer.selection.lines, + }); + if (viewer.selection.lines) { + viewer.jumpToSelection = true; + } + } + }}>Go to Selection + + + diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 2858203..e9e50c8 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -552,6 +552,10 @@ export class MultiFileDiffViewerState { this.fileDetails = tempDetails; this.fileStates = statesArray; + this.stats = this.countStats(); + + await tick(); + await animationFramePromise(); if (this.urlSelection) { const urlSelection = this.urlSelection; @@ -567,7 +571,7 @@ export class MultiFileDiffViewerState { keepFocus: true, }); this.scrollToFile(file.index, { - focus: urlSelection.lines === undefined, + focus: !urlSelection.lines, }); } else { await goto(`?${page.url.searchParams}`, { @@ -576,8 +580,6 @@ export class MultiFileDiffViewerState { } } - this.stats = this.countStats(); - return true; } catch (e) { this.clear(); // Clear any partially loaded state From c9ebf4def08ba4a7cde1e940edad6e592a472db4 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Thu, 27 Nov 2025 09:24:19 -0700 Subject: [PATCH 07/18] Comment out jump to line delays for now --- .../components/diff/ConciseDiffView.svelte | 34 +++++++++++-------- web/src/lib/diff-viewer.svelte.ts | 2 +- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/web/src/lib/components/diff/ConciseDiffView.svelte b/web/src/lib/components/diff/ConciseDiffView.svelte index 383e13a..62f7e08 100644 --- a/web/src/lib/components/diff/ConciseDiffView.svelte +++ b/web/src/lib/components/diff/ConciseDiffView.svelte @@ -164,14 +164,17 @@ {#if searchSegment.highlighted} { 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(() => { - element.scrollIntoView({ block: "center", inline: "center" }); - }, 200); - return () => { - clearTimeout(scheduledJump); - }; + //const scheduledJump = setTimeout(() => { + // jumpToSearchResult = false; + // element.scrollIntoView({ block: "center", inline: "center" }); + //}, 200); + //return () => { + // jumpToSearchResult = false; + // clearTimeout(scheduledJump); + //}; } }} class={{ @@ -215,18 +218,21 @@ 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(() => { - element.scrollIntoView({ block: "center", inline: "center" }); - }, 200); - return () => { - if (scheduledJump) { - clearTimeout(scheduledJump); - } - }; + //const scheduledJump = setTimeout(() => { + // jumpToSelection = false; + // element.scrollIntoView({ block: "center", inline: "center" }); + //}, 200); + //return () => { + // if (scheduledJump) { + // jumpToSelection = false; + // clearTimeout(scheduledJump); + // } + //}; } }} > diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index e9e50c8..82ee1e3 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -562,7 +562,7 @@ export class MultiFileDiffViewerState { this.urlSelection = undefined; const file = this.fileDetails.find((f) => f.toFile === urlSelection.file); if (file && this.diffMetadata.linkable) { - this.jumpToSelection = true; + this.jumpToSelection = urlSelection.lines !== undefined; this.selection = { file, unresolvedLines: urlSelection.lines, From 3a107bdb325c99110fca5b14f6f6338d6778fd11 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Thu, 27 Nov 2025 13:35:37 -0700 Subject: [PATCH 08/18] Handle selection & scroll pos in popstate navigation --- bun.lock | 130 ++++++------------ web-extension/package.json | 8 +- web/package.json | 16 +-- web/src/app.d.ts | 9 +- .../diff/concise-diff-view.svelte.ts | 19 +-- web/src/lib/diff-viewer.svelte.ts | 57 +++++++- web/src/routes/FileHeader.svelte | 28 +++- 7 files changed, 149 insertions(+), 118 deletions(-) diff --git a/bun.lock b/bun.lock index 4c3ff7c..fe71b8a 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "name": "web", "version": "0.0.1", "dependencies": { - "bits-ui": "^2.14.2", + "bits-ui": "^2.14.4", "chroma-js": "^3.1.2", "diff": "^8.0.2", "luxon": "^3.7.2", @@ -22,12 +22,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", @@ -40,15 +40,15 @@ "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", }, }, "web-extension": { @@ -62,17 +62,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", }, }, }, @@ -193,7 +193,7 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@iconify-json/octicon": ["@iconify-json/octicon@1.2.17", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-U/mznjeCeZzuqpP25zWGcF4amLaYnNLs9sTN2hYALa+28n33KUXj/XjLmpAjIUvsyvn91jxfwdxSE79HfM4jCg=="], + "@iconify-json/octicon": ["@iconify-json/octicon@1.2.19", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-q1a9fpyg0Cw/Bt9hEfP86eJlgKtMXzNIRQnsbPZi1MBoHlPyi056TdzV72zY/F+oJSJ8b5Ub8njL2fWs/iLJAg=="], "@iconify/tailwind4": ["@iconify/tailwind4@1.1.0", "", { "dependencies": { "@iconify/tools": "^4.1.4", "@iconify/types": "^2.0.0", "@iconify/utils": "^2.3.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-HqgAYtYk4eFtLvdYfhQrBRT9ohToh+VJJVhHtJ7B4Qhw+J+mRPvGC9Wr6Cgtb36YbIWqBxWuBaAUw9TE/8m2/w=="], @@ -255,12 +255,6 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="], @@ -367,7 +361,7 @@ "@sveltejs/adapter-cloudflare": ["@sveltejs/adapter-cloudflare@7.2.4", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250507.0", "worktop": "0.8.0-next.18" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0", "wrangler": "^4.0.0" } }, "sha512-uD8VlOuGXGuZWL+zbBYSjtmC4WDtlonUodfqAZ/COd5uIy2Z0QptIicB/nkTrGNI9sbmzgf7z0N09CHyWYlUvQ=="], - "@sveltejs/kit": ["@sveltejs/kit@2.48.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g=="], + "@sveltejs/kit": ["@sveltejs/kit@2.49.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="], @@ -409,7 +403,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], - "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -431,8 +425,6 @@ "@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - "@types/tar": ["@types/tar@6.1.13", "", { "dependencies": { "@types/node": "*", "minipass": "^4.0.0" } }, "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -443,41 +435,41 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/type-utils": "8.46.3", "@typescript-eslint/utils": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/type-utils": "8.48.0", "@typescript-eslint/utils": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", "@typescript-eslint/typescript-estree": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.3", "@typescript-eslint/types": "^8.46.3", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.0", "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.3", "", { "dependencies": { "@typescript-eslint/types": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3" } }, "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0" } }, "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.3", "", { "dependencies": { "@typescript-eslint/types": "8.46.3", "@typescript-eslint/typescript-estree": "8.46.3", "@typescript-eslint/utils": "8.46.3", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.46.3", "", {}, "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.48.0", "", {}, "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.3", "@typescript-eslint/tsconfig-utils": "8.46.3", "@typescript-eslint/types": "8.46.3", "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.0", "@typescript-eslint/tsconfig-utils": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", "@typescript-eslint/typescript-estree": "8.46.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.3", "", { "dependencies": { "@typescript-eslint/types": "8.46.3", "eslint-visitor-keys": "^4.2.1" } }, "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitest/expect": ["@vitest/expect@4.0.8", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "chai": "^6.2.0", "tinyrainbow": "^3.0.3" } }, "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA=="], + "@vitest/expect": ["@vitest/expect@4.0.13", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.13", "@vitest/utils": "4.0.13", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-zYtcnNIBm6yS7Gpr7nFTmq8ncowlMdOJkWLqYvhr/zweY6tFbDkDi8BPPOeHxEtK1rSI69H7Fd4+1sqvEGli6w=="], - "@vitest/mocker": ["@vitest/mocker@4.0.8", "", { "dependencies": { "@vitest/spy": "4.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg=="], + "@vitest/mocker": ["@vitest/mocker@4.0.13", "", { "dependencies": { "@vitest/spy": "4.0.13", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-eNCwzrI5djoauklwP1fuslHBjrbR8rqIVbvNlAnkq1OTa6XT+lX68mrtPirNM9TnR69XUPt4puBCx2Wexseylg=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.13", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-ooqfze8URWbI2ozOeLDMh8YZxWDpGXoeY3VOgcDnsUxN0jPyPWSUvjPQWqDGCBks+opWlN1E4oP1UYl3C/2EQA=="], - "@vitest/runner": ["@vitest/runner@4.0.8", "", { "dependencies": { "@vitest/utils": "4.0.8", "pathe": "^2.0.3" } }, "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ=="], + "@vitest/runner": ["@vitest/runner@4.0.13", "", { "dependencies": { "@vitest/utils": "4.0.13", "pathe": "^2.0.3" } }, "sha512-9IKlAru58wcVaWy7hz6qWPb2QzJTKt+IOVKjAx5vb5rzEFPTL6H4/R9BMvjZ2ppkxKgTrFONEJFtzvnyEpiT+A=="], - "@vitest/snapshot": ["@vitest/snapshot@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw=="], + "@vitest/snapshot": ["@vitest/snapshot@4.0.13", "", { "dependencies": { "@vitest/pretty-format": "4.0.13", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-hb7Usvyika1huG6G6l191qu1urNPsq1iFc2hmdzQY3F5/rTgqQnwwplyf8zoYHkpt7H6rw5UfIw6i/3qf9oSxQ=="], - "@vitest/spy": ["@vitest/spy@4.0.8", "", {}, "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA=="], + "@vitest/spy": ["@vitest/spy@4.0.13", "", {}, "sha512-hSu+m4se0lDV5yVIcNWqjuncrmBgwaXa2utFLIrBkQCQkt+pSwyZTPFQAZiiF/63j8jYa8uAeUZ3RSfcdWaYWw=="], - "@vitest/utils": ["@vitest/utils@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "tinyrainbow": "^3.0.3" } }, "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow=="], + "@vitest/utils": ["@vitest/utils@4.0.13", "", { "dependencies": { "@vitest/pretty-format": "4.0.13", "tinyrainbow": "^3.0.3" } }, "sha512-ydozWyQ4LZuu8rLp47xFUWis5VOKMdHjXCWhs1LuJsTNKww+pTHQNK4e0assIB9K80TxFyskENL6vCu3j34EYA=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -503,7 +495,7 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "bits-ui": ["bits-ui@2.14.2", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-YqpAJj/nRTZjf7IlgUC3QlepVZ7YFiAQWpZaYUOAZFW5Py+g5DYkhEDTdNFI5SReo7l1rct/nRpMK4pfL9Xffw=="], + "bits-ui": ["bits-ui@2.14.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], @@ -511,11 +503,9 @@ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -523,7 +513,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.0", "", {}, "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA=="], + "chai": ["chai@6.2.1", "", {}, "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -541,7 +531,7 @@ "chroma-js": ["chroma-js@3.1.2", "", {}, "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg=="], - "chrome-types": ["chrome-types@0.1.387", "", {}, "sha512-AK8Zm8JUb0dTu1ftcqsjcRpZTEaCjHnIfeWNVoRBVMSV4WRJDaWRbTBPIVCRmKZY/kc5Qiw5yfi0gwC7PcuQ5Q=="], + "chrome-types": ["chrome-types@0.1.390", "", {}, "sha512-D5WVDIXTFORzF+zjsDkLy7RJBN0bqEDtMovwSOW22w+mAY3PH85zViqwz6vfWPoznLzFvFjARDT3iEVoxAmF+Q=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -577,8 +567,6 @@ "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -669,22 +657,16 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -751,8 +733,6 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -815,7 +795,7 @@ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], - "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -823,8 +803,6 @@ "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], @@ -835,8 +813,6 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -939,8 +915,6 @@ "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], @@ -953,14 +927,10 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="], "rollup": ["rollup@4.44.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.2", "@rollup/rollup-android-arm64": "4.44.2", "@rollup/rollup-darwin-arm64": "4.44.2", "@rollup/rollup-darwin-x64": "4.44.2", "@rollup/rollup-freebsd-arm64": "4.44.2", "@rollup/rollup-freebsd-x64": "4.44.2", "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", "@rollup/rollup-linux-arm-musleabihf": "4.44.2", "@rollup/rollup-linux-arm64-gnu": "4.44.2", "@rollup/rollup-linux-arm64-musl": "4.44.2", "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", "@rollup/rollup-linux-riscv64-gnu": "4.44.2", "@rollup/rollup-linux-riscv64-musl": "4.44.2", "@rollup/rollup-linux-s390x-gnu": "4.44.2", "@rollup/rollup-linux-x64-gnu": "4.44.2", "@rollup/rollup-linux-x64-musl": "4.44.2", "@rollup/rollup-win32-arm64-msvc": "4.44.2", "@rollup/rollup-win32-ia32-msvc": "4.44.2", "@rollup/rollup-win32-x64-msvc": "4.44.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "runed": ["runed@0.36.0", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0", "zod": "^4.1.0" }, "optionalPeers": ["@sveltejs/kit", "zod"] }, "sha512-CK84KPwAausPQEyWF9t6miCuNW5isAKPMswDsz7jhdueiZZ9du/UrgWc/aggLts8QuppT8KucryrHDFBAqk9Ww=="], "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], @@ -1003,11 +973,11 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "svelte": ["svelte@5.43.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-tPNp21nDWB0PSHE+VrTvEy9cFtDp2Q+ATxQoFomISEVdikZ1QZ69UqBPz/LlT+Oc8/LYS/COYwDQZrmZEUr+JQ=="], + "svelte": ["svelte@5.43.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-pHeUrp1A5S6RGaXhJB7PtYjL1VVjbVrJ2EfuAoPu9/1LeoMaJa/pcdCsCSb0gS4eUHAHnhCbUDxORZyvGK6kOQ=="], "svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="], - "svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="], + "svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="], "svelte-eslint-parser": ["svelte-eslint-parser@1.4.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA=="], @@ -1031,8 +1001,6 @@ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1047,7 +1015,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.46.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.3", "@typescript-eslint/parser": "8.46.3", "@typescript-eslint/typescript-estree": "8.46.3", "@typescript-eslint/utils": "8.46.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA=="], + "typescript-eslint": ["typescript-eslint@8.48.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.0", "@typescript-eslint/parser": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/utils": "8.48.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw=="], "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], @@ -1077,11 +1045,11 @@ "virtua": ["virtua@0.47.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-IGe/hnZJdywFtFqpmtvS25II/Ov7i4vWnyagdguxLPvM8bSLmfEZULChdmTYwfcWI2XnxX+VGV4GpgaOvGp+7g=="], - "vite": ["vite@7.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ=="], + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], - "vitest": ["vitest@4.0.8", "", { "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", "@vitest/pretty-format": "4.0.8", "@vitest/runner": "4.0.8", "@vitest/snapshot": "4.0.8", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.8", "@vitest/browser-preview": "4.0.8", "@vitest/browser-webdriverio": "4.0.8", "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg=="], + "vitest": ["vitest@4.0.13", "", { "dependencies": { "@vitest/expect": "4.0.13", "@vitest/mocker": "4.0.13", "@vitest/pretty-format": "4.0.13", "@vitest/runner": "4.0.13", "@vitest/snapshot": "4.0.13", "@vitest/spy": "4.0.13", "@vitest/utils": "4.0.13", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.13", "@vitest/browser-preview": "4.0.13", "@vitest/browser-webdriverio": "4.0.13", "@vitest/ui": "4.0.13", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-QSD4I0fN6uZQfftryIXuqvqgBxTvJ3ZNkF6RWECd82YGAYAfhcppBLFXzXJHQAAhVFyYEuFTrq6h0hQqjB7jIQ=="], "web": ["web@workspace:web"], @@ -1161,8 +1129,6 @@ "@sveltejs/vite-plugin-svelte-inspector/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="], @@ -1179,10 +1145,6 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@vitest/mocker/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "@vitest/snapshot/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "bits-ui/runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -1193,12 +1155,8 @@ "esrap/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "miniflare/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], @@ -1211,14 +1169,10 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "svelte-check/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], - "svelte-toolbelt/runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - "vitest/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], diff --git a/web-extension/package.json b/web-extension/package.json index d8dbae3..57f1023 100644 --- a/web-extension/package.json +++ b/web-extension/package.json @@ -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", diff --git a/web/package.json b/web/package.json index 680ce18..1c88cae 100644 --- a/web/package.json +++ b/web/package.json @@ -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", @@ -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", diff --git a/web/src/app.d.ts b/web/src/app.d.ts index d76242a..514ec23 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -5,7 +5,14 @@ declare global { // interface Error {} // interface Locals {} // interface PageData {} - // interface PageState {} + interface PageState { + scrollOffset?: number; + selection?: { + fileIdx: number; + lines?: LineSelection; + unresolvedLines?: UnresolvedLineSelection; + }; + } // interface Platform {} } } diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index 01fa423..c54033f 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -1260,24 +1260,19 @@ export class ConciseDiffViewState { return; } - const destroyClick = on(element, "click", (e) => { - // Only handle click if we didn't just finish dragging - if (this.suppressNextClick) { - this.suppressNextClick = false; - return; - } - this.updateSelection(hunk, hunkIdx, line, lineIdx, e.shiftKey); - }); - const destroyPointerDown = on(element, "pointerdown", (e: PointerEvent) => { - // Only start drag on left click without shift key - if (e.button === 0 && !e.shiftKey) { + if (e.button !== 0) return; // only handle left click + + if (e.shiftKey) { + // Handle shift+click for adjusting selection + this.updateSelection(hunk, hunkIdx, line, lineIdx, true); + } else { + // Handle regular click with drag support this.startDrag(element, e.pointerId, hunk, hunkIdx, line, lineIdx); } }); return () => { - destroyClick(); destroyPointerDown(); }; }; diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 82ee1e3..071fb98 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -29,7 +29,8 @@ import { ProgressBarState } from "$lib/components/progress-bar/index.svelte"; import { Keybinds } from "./keybinds.svelte"; import { LayoutState, type PersistentLayoutState } from "./layout.svelte"; import { page } from "$app/state"; -import { goto } from "$app/navigation"; +import { afterNavigate, goto } from "$app/navigation"; +import { type AfterNavigate } from "@sveltejs/kit"; export const GITHUB_URL_PARAM = "github_url"; export const PATCH_URL_PARAM = "patch_url"; @@ -320,12 +321,43 @@ export class MultiFileDiffViewerState { } }); + afterNavigate((nav) => { + this.afterNavigate(nav); + }); + + this.registerKeybinds(); + } + + private registerKeybinds() { const keybinds = new Keybinds(); keybinds.registerModifierBind("o", () => this.openOpenDiffDialog()); keybinds.registerModifierBind(",", () => this.openSettingsDialog()); keybinds.registerModifierBind("b", () => this.layoutState.toggleSidebar()); } + private afterNavigate(nav: AfterNavigate) { + if (!this.vlist) return; + + if (nav.type === "popstate") { + const selection = page.state.selection; + const file = selection ? this.fileDetails[selection.fileIdx] : undefined; + if (selection && file) { + this.selection = { + file, + lines: selection.lines, + unresolvedLines: selection.unresolvedLines, + }; + } else { + this.selection = undefined; + } + + const scrollOffset = page.state.scrollOffset; + if (scrollOffset !== undefined) { + this.vlist.scrollTo(scrollOffset); + } + } + } + openOpenDiffDialog() { this.openDiffDialogOpen = true; this.settingsDialogOpen = false; @@ -378,11 +410,25 @@ export class MultiFileDiffViewerState { return null; } + private createPageState(): App.PageState { + return { + scrollOffset: this.vlist?.getScrollOffset(), + selection: this.selection + ? { + fileIdx: this.selection.file.index, + lines: $state.snapshot(this.selection.lines), + unresolvedLines: $state.snapshot(this.selection.unresolvedLines), + } + : undefined, + }; + } + setSelection(file: FileDetails, lines: LineSelection | undefined) { this.selection = { file, lines }; goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { keepFocus: true, + state: this.createPageState(), }); } @@ -391,6 +437,7 @@ export class MultiFileDiffViewerState { goto(`?${page.url.searchParams}`, { keepFocus: true, + state: this.createPageState(), }); } @@ -567,12 +614,14 @@ export class MultiFileDiffViewerState { file, unresolvedLines: urlSelection.lines, }; - await goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { - keepFocus: true, - }); this.scrollToFile(file.index, { focus: !urlSelection.lines, }); + await animationFramePromise(); + await goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { + keepFocus: true, + state: this.createPageState(), + }); } else { await goto(`?${page.url.searchParams}`, { keepFocus: true, diff --git a/web/src/routes/FileHeader.svelte b/web/src/routes/FileHeader.svelte index 17af420..f2b9ddf 100644 --- a/web/src/routes/FileHeader.svelte +++ b/web/src/routes/FileHeader.svelte @@ -4,6 +4,7 @@ import { type FileDetails, MultiFileDiffViewerState } from "$lib/diff-viewer.svelte"; import { GlobalOptions } from "$lib/global-options.svelte"; import { Popover, Button } from "bits-ui"; + import { boolAttr } from "runed"; import { tick } from "svelte"; interface Props { @@ -48,6 +49,11 @@ viewer.scrollToFile(index, { autoExpand: false, smooth: true }); viewer.setSelection(value, undefined); } + + let selected = $derived.by(() => { + const sel = viewer.getSelection(value); + return sel && sel.lines === undefined && sel.unresolvedLines === undefined; + }); {#snippet fileName()} @@ -116,11 +122,15 @@
selectHeader()} onkeyup={(event) => event.key === "Enter" && selectHeader()} + data-selected={boolAttr(selected)} > {#if value.type === "text"} @@ -136,3 +146,19 @@ {/if}
+ + From a28dea6873f6622dd6892cc630494403c11170aa Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:46:44 -0700 Subject: [PATCH 09/18] Add missing state param and todo --- web/src/lib/diff-viewer.svelte.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 071fb98..739360b 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -321,9 +321,7 @@ export class MultiFileDiffViewerState { } }); - afterNavigate((nav) => { - this.afterNavigate(nav); - }); + afterNavigate((nav) => this.afterNavigate(nav)); this.registerKeybinds(); } @@ -605,6 +603,8 @@ export class MultiFileDiffViewerState { await animationFramePromise(); if (this.urlSelection) { + // TODO: This does store store the proper scroll offset on initial load + const urlSelection = this.urlSelection; this.urlSelection = undefined; const file = this.fileDetails.find((f) => f.toFile === urlSelection.file); @@ -625,6 +625,7 @@ export class MultiFileDiffViewerState { } else { await goto(`?${page.url.searchParams}`, { keepFocus: true, + state: this.createPageState(), }); } } From 92665c330c5bb4991e2db10462917fb020cb5ef9 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:59:25 -0700 Subject: [PATCH 10/18] Don't create history entries when new selection matches old --- .../diff/concise-diff-view.svelte.ts | 6 +++++ web/src/lib/diff-viewer.svelte.ts | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index c54033f..eae8b14 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -55,6 +55,12 @@ export interface LineSelection { end: LineRef; } +export function lineSelectionsEqual(a: LineSelection | undefined, b: LineSelection | undefined): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + return a.hunk === b.hunk && a.start.idx === b.start.idx && a.end.idx === b.end.idx; +} + export interface UnresolvedLineSelection { start: UnresolvedLineRef; end: UnresolvedLineRef; diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 739360b..8fa45e0 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -18,6 +18,7 @@ import { writeLineRef, parseLineRef, type UnresolvedLineSelection, + lineSelectionsEqual, } from "$lib/components/diff/concise-diff-view.svelte"; import { countOccurrences, type FileTreeNodeData, makeFileTree, type LazyPromise, lazyPromise, animationFramePromise, yieldToBrowser } from "$lib/util"; import { onDestroy, onMount, tick } from "svelte"; @@ -422,21 +423,28 @@ export class MultiFileDiffViewerState { } setSelection(file: FileDetails, lines: LineSelection | undefined) { + const oldSelection = this.selection; this.selection = { file, lines }; + const selectionChanged = oldSelection?.file.index !== file.index || !lineSelectionsEqual(oldSelection?.lines, lines); - goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { - keepFocus: true, - state: this.createPageState(), - }); + if (selectionChanged) { + goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { + keepFocus: true, + state: this.createPageState(), + }); + } } clearSelection() { + const oldSelection = this.selection; this.selection = undefined; - goto(`?${page.url.searchParams}`, { - keepFocus: true, - state: this.createPageState(), - }); + if (oldSelection !== undefined) { + goto(`?${page.url.searchParams}`, { + keepFocus: true, + state: this.createPageState(), + }); + } } scrollToFile(index: number, options: { autoExpand?: boolean; smooth?: boolean; focus?: boolean } = {}) { @@ -604,7 +612,7 @@ export class MultiFileDiffViewerState { if (this.urlSelection) { // TODO: This does store store the proper scroll offset on initial load - + const urlSelection = this.urlSelection; this.urlSelection = undefined; const file = this.fileDetails.find((f) => f.toFile === urlSelection.file); From 0bf0552b7077b766e9f58713d4d6187ae9dc437d Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:47:23 -0700 Subject: [PATCH 11/18] Fix history spam when loading diff from url (with selection) --- .../diff/concise-diff-view.svelte.ts | 6 ---- web/src/lib/diff-viewer.svelte.ts | 31 ++++++++++++++----- web/src/lib/open-diff-dialog.svelte.ts | 10 +++--- web/src/routes/OpenDiffDialog.svelte | 8 ++--- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index eae8b14..c54033f 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -55,12 +55,6 @@ export interface LineSelection { end: LineRef; } -export function lineSelectionsEqual(a: LineSelection | undefined, b: LineSelection | undefined): boolean { - if (!a && !b) return true; - if (!a || !b) return false; - return a.hunk === b.hunk && a.start.idx === b.start.idx && a.end.idx === b.end.idx; -} - export interface UnresolvedLineSelection { start: UnresolvedLineRef; end: UnresolvedLineRef; diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 8fa45e0..ff9bd78 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -18,7 +18,6 @@ import { writeLineRef, parseLineRef, type UnresolvedLineSelection, - lineSelectionsEqual, } from "$lib/components/diff/concise-diff-view.svelte"; import { countOccurrences, type FileTreeNodeData, makeFileTree, type LazyPromise, lazyPromise, animationFramePromise, yieldToBrowser } from "$lib/util"; import { onDestroy, onMount, tick } from "svelte"; @@ -30,7 +29,7 @@ import { ProgressBarState } from "$lib/components/progress-bar/index.svelte"; import { Keybinds } from "./keybinds.svelte"; import { LayoutState, type PersistentLayoutState } from "./layout.svelte"; import { page } from "$app/state"; -import { afterNavigate, goto } from "$app/navigation"; +import { afterNavigate, goto, replaceState } from "$app/navigation"; import { type AfterNavigate } from "@sveltejs/kit"; export const GITHUB_URL_PARAM = "github_url"; @@ -105,6 +104,25 @@ export interface Selection { unresolvedLines?: UnresolvedLineSelection; } +/** + * Checks whether two selections refer to the same unresolved lines + * in the same file. + * + * @param a selection a + * @param b selection b + * @returns true if the selections are semantically equal + */ +export function selectionsSemanticallyEqual(a: Selection | undefined, b: Selection | undefined): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + const linesA = a.lines ?? a.unresolvedLines!; + const linesB = b.lines ?? b.unresolvedLines!; + const startEquals = linesA.start.no === linesB.start.no && linesA.start.new === linesB.start.new; + if (!startEquals) return false; + const endEquals = linesA.end.no === linesB.end.no && linesA.end.new === linesB.end.new; + return endEquals; +} + function makeUrlHashValue(selection: Selection): string { let hash = encodeURIComponent(selection.file.toFile); if (selection.lines) { @@ -425,9 +443,8 @@ export class MultiFileDiffViewerState { setSelection(file: FileDetails, lines: LineSelection | undefined) { const oldSelection = this.selection; this.selection = { file, lines }; - const selectionChanged = oldSelection?.file.index !== file.index || !lineSelectionsEqual(oldSelection?.lines, lines); - if (selectionChanged) { + if (!selectionsSemanticallyEqual(oldSelection, this.selection)) { goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { keepFocus: true, state: this.createPageState(), @@ -626,10 +643,8 @@ export class MultiFileDiffViewerState { focus: !urlSelection.lines, }); await animationFramePromise(); - await goto(`?${page.url.searchParams}#${makeUrlHashValue(this.selection)}`, { - keepFocus: true, - state: this.createPageState(), - }); + // TODO: restoring an unresolved selection does not work until something triggers resolveOrUpdateSelection + replaceState(page.url, this.createPageState()); } else { await goto(`?${page.url.searchParams}`, { keepFocus: true, diff --git a/web/src/lib/open-diff-dialog.svelte.ts b/web/src/lib/open-diff-dialog.svelte.ts index b492337..3c1d2bf 100644 --- a/web/src/lib/open-diff-dialog.svelte.ts +++ b/web/src/lib/open-diff-dialog.svelte.ts @@ -258,7 +258,7 @@ export class OpenDiffDialogState { return into; } - async handlePatchFile() { + async handlePatchFile(fromUrl: boolean) { if (!this.patchFile || !this.patchFile.metadata) { alert("No patch file selected."); return; @@ -290,10 +290,12 @@ export class OpenDiffDialogState { if (this.patchFile.mode === "url") { patchUrl = this.patchFile.url; } - await this.updateUrlParams({ patchUrl }); + if (!fromUrl) { + await this.updateUrlParams({ patchUrl }); + } } - async handleGithubUrl() { + async handleGithubUrl(fromUrl: boolean) { const url = new URL(this.githubUrl); // exclude hash + query params const test = url.protocol + "//" + url.hostname + url.pathname; @@ -309,7 +311,7 @@ export class OpenDiffDialogState { this.githubUrl = match[0]; this.props.open.current = false; const success = await this.viewer.loadFromGithubApi(match); - if (success) { + if (success && !fromUrl) { await this.updateUrlParams({ githubUrl: this.githubUrl }); return; } diff --git a/web/src/routes/OpenDiffDialog.svelte b/web/src/routes/OpenDiffDialog.svelte index 3c2c45c..baa1f1f 100644 --- a/web/src/routes/OpenDiffDialog.svelte +++ b/web/src/routes/OpenDiffDialog.svelte @@ -26,12 +26,12 @@ if (githubUrlParam !== null) { instance.githubUrl = githubUrlParam; - await instance.handleGithubUrl(); + await instance.handleGithubUrl(true); } else if (patchUrlParam !== null) { instance.patchFile.reset(); instance.patchFile.mode = "url"; instance.patchFile.url = patchUrlParam; - await instance.handlePatchFile(); + await instance.handlePatchFile(true); } else { open = true; } @@ -100,7 +100,7 @@ class="flex flex-row" onsubmit={(e) => { e.preventDefault(); - instance.handleGithubUrl(); + instance.handleGithubUrl(false); }} > { e.preventDefault(); - instance.handlePatchFile(); + instance.handlePatchFile(false); }} >

From 9748cece9144ebb6148d5d875de9e26c75e54806 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:51:55 -0700 Subject: [PATCH 12/18] Fix open diff dialog opening when loading gh diff from url --- web/src/lib/open-diff-dialog.svelte.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/lib/open-diff-dialog.svelte.ts b/web/src/lib/open-diff-dialog.svelte.ts index 3c1d2bf..47b2a9c 100644 --- a/web/src/lib/open-diff-dialog.svelte.ts +++ b/web/src/lib/open-diff-dialog.svelte.ts @@ -311,8 +311,10 @@ export class OpenDiffDialogState { this.githubUrl = match[0]; this.props.open.current = false; const success = await this.viewer.loadFromGithubApi(match); - if (success && !fromUrl) { - await this.updateUrlParams({ githubUrl: this.githubUrl }); + if (success) { + if (!fromUrl) { + await this.updateUrlParams({ githubUrl: this.githubUrl }); + } return; } this.props.open.current = true; From fd33e641db9b7d40dfc8f62bb047862bc7693e5a Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:30:26 -0700 Subject: [PATCH 13/18] cleanup navigation logic on loading new diffs --- web/src/app.d.ts | 5 ++ web/src/lib/diff-viewer.svelte.ts | 117 +++++++++++++++++-------- web/src/lib/open-diff-dialog.svelte.ts | 51 +++-------- web/src/routes/OpenDiffDialog.svelte | 8 +- 4 files changed, 100 insertions(+), 81 deletions(-) diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 514ec23..19b3cb1 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -6,6 +6,11 @@ declare global { // interface Locals {} // interface PageData {} 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; diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index ff9bd78..521f04a 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -29,7 +29,7 @@ import { ProgressBarState } from "$lib/components/progress-bar/index.svelte"; import { Keybinds } from "./keybinds.svelte"; import { LayoutState, type PersistentLayoutState } from "./layout.svelte"; import { page } from "$app/state"; -import { afterNavigate, goto, replaceState } from "$app/navigation"; +import { afterNavigate, goto, pushState, replaceState } from "$app/navigation"; import { type AfterNavigate } from "@sveltejs/kit"; export const GITHUB_URL_PARAM = "github_url"; @@ -115,8 +115,11 @@ export interface Selection { export function selectionsSemanticallyEqual(a: Selection | undefined, b: Selection | undefined): boolean { if (!a && !b) return true; if (!a || !b) return false; - const linesA = a.lines ?? a.unresolvedLines!; - const linesB = b.lines ?? b.unresolvedLines!; + if (a.file !== b.file) return false; + const linesA = a.lines ?? a.unresolvedLines; + const linesB = b.lines ?? b.unresolvedLines; + if (!linesA && !linesB) return true; + if (!linesA || !linesB) return false; const startEquals = linesA.start.no === linesB.start.no && linesA.start.new === linesB.start.new; if (!startEquals) return false; const endEquals = linesA.end.no === linesB.end.no && linesA.end.new === linesB.end.new; @@ -269,10 +272,31 @@ export interface GithubDiffMetadata extends BaseDiffMetadata { export interface FileDiffMetadata extends BaseDiffMetadata { type: "file"; fileName: string; + url?: string; } export type DiffMetadata = GithubDiffMetadata | FileDiffMetadata; +export function updateSearchParams(params: URLSearchParams, metadata: DiffMetadata) { + if (metadata.type === "github") { + params.set(GITHUB_URL_PARAM, metadata.details.backlink); + } else { + params.delete(GITHUB_URL_PARAM); + } + if (metadata.type === "file" && metadata.url) { + params.set(PATCH_URL_PARAM, metadata.url); + } else { + params.delete(PATCH_URL_PARAM); + } +} + +export interface LoadPatchesOptions { + /** + * default: push + */ + state?: "push" | "replace"; +} + export class MultiFileDiffViewerState { private static readonly context = new Context("MultiFileDiffViewerState"); @@ -368,9 +392,24 @@ export class MultiFileDiffViewerState { this.selection = undefined; } - const scrollOffset = page.state.scrollOffset; - if (scrollOffset !== undefined) { - this.vlist.scrollTo(scrollOffset); + if (page.state.initialLoad ?? false) { + if (this.selection) { + // TODO: restoring an unresolved selection does not work until something triggers resolveOrUpdateSelection + const hasLines = this.selection.lines || this.selection.unresolvedLines; + this.scrollToFile(this.selection.file.index, { + focus: !hasLines, + }); + if (hasLines) { + this.jumpToSelection = true; + } + } else { + this.vlist.scrollTo(0); + } + } else { + const scrollOffset = page.state.scrollOffset; + if (scrollOffset !== undefined) { + this.vlist.scrollTo(scrollOffset); + } } } } @@ -427,8 +466,9 @@ export class MultiFileDiffViewerState { return null; } - private createPageState(): App.PageState { + private createPageState(opts?: { initialLoad?: boolean }): App.PageState { return { + initialLoad: opts?.initialLoad ?? false, scrollOffset: this.vlist?.getScrollOffset(), selection: this.selection ? { @@ -546,7 +586,7 @@ export class MultiFileDiffViewerState { this.vlist?.scrollToIndex(0, { align: "start" }); } - async loadPatches(meta: () => Promise, patches: () => Promise>) { + async loadPatches(meta: () => Promise, patches: () => Promise>, opts?: LoadPatchesOptions) { if (this.loadingState.loading) { alert("Already loading patches, please wait."); return false; @@ -627,30 +667,32 @@ export class MultiFileDiffViewerState { await tick(); await animationFramePromise(); - if (this.urlSelection) { - // TODO: This does store store the proper scroll offset on initial load - - const urlSelection = this.urlSelection; - this.urlSelection = undefined; - const file = this.fileDetails.find((f) => f.toFile === urlSelection.file); - if (file && this.diffMetadata.linkable) { - this.jumpToSelection = urlSelection.lines !== undefined; - this.selection = { - file, - unresolvedLines: urlSelection.lines, - }; - this.scrollToFile(file.index, { - focus: !urlSelection.lines, - }); - await animationFramePromise(); - // TODO: restoring an unresolved selection does not work until something triggers resolveOrUpdateSelection - replaceState(page.url, this.createPageState()); - } else { - await goto(`?${page.url.searchParams}`, { - keepFocus: true, - state: this.createPageState(), - }); - } + const urlSelection = this.urlSelection; + this.urlSelection = undefined; + const selectedFile = urlSelection !== undefined ? this.fileDetails.find((f) => f.toFile === urlSelection.file) : undefined; + + if (urlSelection && selectedFile && this.diffMetadata.linkable) { + this.jumpToSelection = urlSelection.lines !== undefined; + this.selection = { + file: selectedFile, + unresolvedLines: urlSelection.lines, + }; + this.scrollToFile(selectedFile.index, { + focus: !urlSelection.lines, + }); + await animationFramePromise(); + } + + const newUrl = new URL(page.url); + updateSearchParams(newUrl.searchParams, this.diffMetadata); + if (this.selection) { + newUrl.hash = makeUrlHashValue(this.selection); + } + const link = `${newUrl.search}${newUrl.hash}`; + if (opts?.state === "replace") { + replaceState(link, this.createPageState({ initialLoad: true })); + } else { + pushState(link, this.createPageState({ initialLoad: true })); } return true; @@ -668,7 +710,7 @@ export class MultiFileDiffViewerState { } } - private async loadPatchesGithub(resultOrPromise: Promise | GithubDiffResult) { + private async loadPatchesGithub(resultOrPromise: Promise | GithubDiffResult, opts?: LoadPatchesOptions) { return await this.loadPatches( async () => { const result = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise; @@ -678,20 +720,21 @@ export class MultiFileDiffViewerState { const result = resultOrPromise instanceof Promise ? await resultOrPromise : resultOrPromise; return parseMultiFilePatchGithub(await result.info, await result.response, this.loadingState); }, + opts, ); } // TODO fails for initial commit? // handle matched github url - async loadFromGithubApi(match: Array): Promise { + async loadFromGithubApi(match: Array, opts?: LoadPatchesOptions): Promise { const [url, owner, repo, type, id] = match; const token = getGithubToken(); try { if (type === "commit") { - return await this.loadPatchesGithub(fetchGithubCommitDiff(token, owner, repo, id.split("/")[0])); + return await this.loadPatchesGithub(fetchGithubCommitDiff(token, owner, repo, id.split("/")[0]), opts); } else if (type === "pull") { - return await this.loadPatchesGithub(fetchGithubPRComparison(token, owner, repo, id.split("/")[0])); + return await this.loadPatchesGithub(fetchGithubPRComparison(token, owner, repo, id.split("/")[0]), opts); } else if (type === "compare") { let refs = id.split("..."); if (refs.length !== 2) { @@ -703,7 +746,7 @@ export class MultiFileDiffViewerState { } const base = refs[0]; const head = refs[1]; - return await this.loadPatchesGithub(fetchGithubComparison(token, owner, repo, base, head)); + return await this.loadPatchesGithub(fetchGithubComparison(token, owner, repo, base, head), opts); } } catch (error) { console.error(error); diff --git a/web/src/lib/open-diff-dialog.svelte.ts b/web/src/lib/open-diff-dialog.svelte.ts index 47b2a9c..b2bdf6b 100644 --- a/web/src/lib/open-diff-dialog.svelte.ts +++ b/web/src/lib/open-diff-dialog.svelte.ts @@ -2,9 +2,7 @@ import type { WritableBoxedValues } from "svelte-toolbelt"; import { DirectoryEntry, FileEntry, MultimodalFileInputState, type MultimodalFileInputValueMetadata } from "./components/files/index.svelte"; import { SvelteSet } from "svelte/reactivity"; import { type FileStatus } from "$lib/github.svelte"; -import { page } from "$app/state"; -import { goto } from "$app/navigation"; -import { GITHUB_URL_PARAM, makeImageDetails, makeTextDetails, MultiFileDiffViewerState, PATCH_URL_PARAM } from "$lib/diff-viewer.svelte"; +import { makeImageDetails, makeTextDetails, MultiFileDiffViewerState, type LoadPatchesOptions } from "$lib/diff-viewer.svelte"; import { binaryFileDummyDetails, bytesEqual, isBinaryFile, isImageFile, parseMultiFilePatch } from "$lib/util"; import { createTwoFilesPatch } from "diff"; @@ -107,7 +105,6 @@ export class OpenDiffDialogState { this.props.open.current = true; return; } - await this.updateUrlParams(); } async *generateSingleImagePatch(fileAMeta: MultimodalFileInputValueMetadata, fileBMeta: MultimodalFileInputValueMetadata, blobA: Blob, blobB: Blob) { @@ -162,7 +159,6 @@ export class OpenDiffDialogState { this.props.open.current = true; return; } - await this.updateUrlParams(); } async *generateDirPatches(dirA: DirectoryEntry, dirB: DirectoryEntry) { @@ -258,7 +254,7 @@ export class OpenDiffDialogState { return into; } - async handlePatchFile(fromUrl: boolean) { + async handlePatchFile(opts?: LoadPatchesOptions) { if (!this.patchFile || !this.patchFile.metadata) { alert("No patch file selected."); return; @@ -276,26 +272,25 @@ export class OpenDiffDialogState { this.props.open.current = false; const success = await this.viewer.loadPatches( async () => { - return { linkable: this.patchFile.mode === "url", type: "file", fileName: meta.name }; + return { + linkable: this.patchFile.mode === "url", + type: "file", + fileName: meta.name, + url: this.patchFile.mode === "url" ? this.patchFile.url : undefined, + }; }, async () => { return parseMultiFilePatch(text, this.viewer.loadingState); }, + opts, ); if (!success) { this.props.open.current = true; return; } - let patchUrl: string | undefined; - if (this.patchFile.mode === "url") { - patchUrl = this.patchFile.url; - } - if (!fromUrl) { - await this.updateUrlParams({ patchUrl }); - } } - async handleGithubUrl(fromUrl: boolean) { + async handleGithubUrl(opts?: LoadPatchesOptions) { const url = new URL(this.githubUrl); // exclude hash + query params const test = url.protocol + "//" + url.hostname + url.pathname; @@ -310,34 +305,10 @@ export class OpenDiffDialogState { this.githubUrl = match[0]; this.props.open.current = false; - const success = await this.viewer.loadFromGithubApi(match); + const success = await this.viewer.loadFromGithubApi(match, opts); if (success) { - if (!fromUrl) { - await this.updateUrlParams({ githubUrl: this.githubUrl }); - } return; } this.props.open.current = true; } - - async updateUrlParams(opts: { githubUrl?: string; patchUrl?: string } = {}) { - const newUrl = new URL(page.url); - if (opts.githubUrl) { - newUrl.searchParams.set(GITHUB_URL_PARAM, opts.githubUrl); - } else { - newUrl.searchParams.delete(GITHUB_URL_PARAM); - } - if (opts.patchUrl) { - newUrl.searchParams.set(PATCH_URL_PARAM, opts.patchUrl); - } else { - newUrl.searchParams.delete(PATCH_URL_PARAM); - } - let params = `?${newUrl.searchParams}`; - if (newUrl.hash) { - params += newUrl.hash; - } - await goto(params, { - keepFocus: true, - }); - } } diff --git a/web/src/routes/OpenDiffDialog.svelte b/web/src/routes/OpenDiffDialog.svelte index baa1f1f..ee20945 100644 --- a/web/src/routes/OpenDiffDialog.svelte +++ b/web/src/routes/OpenDiffDialog.svelte @@ -26,12 +26,12 @@ if (githubUrlParam !== null) { instance.githubUrl = githubUrlParam; - await instance.handleGithubUrl(true); + await instance.handleGithubUrl({ state: "replace" }); } else if (patchUrlParam !== null) { instance.patchFile.reset(); instance.patchFile.mode = "url"; instance.patchFile.url = patchUrlParam; - await instance.handlePatchFile(true); + await instance.handlePatchFile({ state: "replace" }); } else { open = true; } @@ -100,7 +100,7 @@ class="flex flex-row" onsubmit={(e) => { e.preventDefault(); - instance.handleGithubUrl(false); + instance.handleGithubUrl(); }} > { e.preventDefault(); - instance.handlePatchFile(false); + instance.handlePatchFile(); }} >

From 90c034bbcfc51fc6b3cadf42e3b232d1be2fe82b Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:17:37 -0700 Subject: [PATCH 14/18] Fix selection being lost from URL on load --- web/src/lib/components/diff/concise-diff-view.svelte.ts | 2 +- web/src/lib/diff-viewer.svelte.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index c54033f..fbf6385 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -60,7 +60,7 @@ export interface UnresolvedLineSelection { end: UnresolvedLineRef; } -export function writeLineRef(ref: LineRef): string { +export function writeLineRef(ref: UnresolvedLineRef): string { const prefix = ref.new ? "R" : "L"; return prefix + ref.no.toString(); } diff --git a/web/src/lib/diff-viewer.svelte.ts b/web/src/lib/diff-viewer.svelte.ts index 521f04a..5ad5bf6 100644 --- a/web/src/lib/diff-viewer.svelte.ts +++ b/web/src/lib/diff-viewer.svelte.ts @@ -128,11 +128,12 @@ export function selectionsSemanticallyEqual(a: Selection | undefined, b: Selecti function makeUrlHashValue(selection: Selection): string { let hash = encodeURIComponent(selection.file.toFile); - if (selection.lines) { + const lines = selection.lines ?? selection.unresolvedLines; + if (lines) { hash += ":"; - hash += writeLineRef(selection.lines.start); + hash += writeLineRef(lines.start); hash += ":"; - hash += writeLineRef(selection.lines.end); + hash += writeLineRef(lines.end); } return hash; } From 2710137d5a3bcc6c4adf6dec9b6f7db887cae58e Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:25:18 -0700 Subject: [PATCH 15/18] Disable Go to Selection when there is no selection --- web/src/lib/components/menu-bar/MenuBar.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/menu-bar/MenuBar.svelte b/web/src/lib/components/menu-bar/MenuBar.svelte index 56d4f10..c8be409 100644 --- a/web/src/lib/components/menu-bar/MenuBar.svelte +++ b/web/src/lib/components/menu-bar/MenuBar.svelte @@ -123,7 +123,8 @@ { if (viewer.selection) { viewer.scrollToFile(viewer.selection.file.index, { @@ -133,8 +134,10 @@ viewer.jumpToSelection = true; } } - }}>Go to Selection + Go to Selection + From 4cccb2b02a0b1121894438d1db90a24cb613ae67 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:37:51 -0700 Subject: [PATCH 16/18] Add indicator for when file/lines in file are selected to file tree --- web/src/routes/Sidebar.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/web/src/routes/Sidebar.svelte b/web/src/routes/Sidebar.svelte index 7505e0a..f315f2c 100644 --- a/web/src/routes/Sidebar.svelte +++ b/web/src/routes/Sidebar.svelte @@ -5,6 +5,7 @@ import { type TreeNode } from "$lib/components/tree/index.svelte"; import { on } from "svelte/events"; import { type Attachment } from "svelte/attachments"; + import { boolAttr } from "runed"; const viewer = MultiFileDiffViewerState.get(); @@ -76,13 +77,14 @@
{#snippet fileSnippet(value: FileDetails)}
scrollToFileClick(e, value.index)} {@attach focusFileDoubleClick(value)} onkeydown={(e) => e.key === "Enter" && viewer.scrollToFile(value.index)} role="button" tabindex="0" id={"file-tree-file-" + value.index} + data-selected={boolAttr(viewer.selection?.file.index === value.index)} > From 1ec5f28f3ff8f449fbca731c9a56fa819f7792ec Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:05:45 -0700 Subject: [PATCH 17/18] Fix FileHeader focus/selected styles overriding --tw-shadow --- web/src/routes/FileHeader.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/routes/FileHeader.svelte b/web/src/routes/FileHeader.svelte index f2b9ddf..85192bf 100644 --- a/web/src/routes/FileHeader.svelte +++ b/web/src/routes/FileHeader.svelte @@ -150,13 +150,18 @@