Skip to content

Commit dbd8a49

Browse files
matt2eclaude
andauthored
feat(diff-viewer): add inline diff computation with line-level and character-level highlights (#545)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cde08f6 commit dbd8a49

8 files changed

Lines changed: 1168 additions & 8 deletions

File tree

apps/staged/src/app.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
--diff-added-bg: rgba(63, 185, 80, 0.08);
5555
--diff-removed-bg: rgba(248, 81, 73, 0.08);
5656
--diff-changed-bg: rgba(255, 255, 255, 0.04);
57+
--diff-modified-bg: rgba(227, 179, 65, 0.08);
58+
--diff-modified-inline-bg: rgba(227, 179, 65, 0.25);
5759
--diff-range-border: #524d58;
5860
--diff-comment-highlight: rgba(88, 166, 255, 0.5);
5961

apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export default class BranchCardSessionManager {
6969
/** True when a new session will be queued rather than started immediately. */
7070
willQueue = $derived(
7171
!this.getTimeline() || // provisioning — no timeline yet
72-
this.hasRunningSession // another session is active
72+
this.hasRunningSession || // another session is active
73+
this.isSessionStartPending // a session start is already in flight
7374
);
7475

7576
/** True when new session actions (new commit, note, review) should be disabled. */

packages/diff-viewer/src/lib/components/DiffViewer.svelte

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
isLineInChangedAlignment as helperIsLineInChangedAlignment,
5555
isLineInIndexedRange,
5656
isLineSelected as helperIsLineSelected,
57+
getLineClass as helperGetLineClass,
58+
getCharHighlights as helperGetCharHighlights,
5759
buildLineCommentEditorLayout,
5860
buildLineSelectionToolbarLayout,
5961
buildRangeCommentEditorLayout,
@@ -63,6 +65,8 @@
6365
normalizeLineSelection,
6466
resolveLineSelectionToolbarLeft,
6567
} from '../utils/diffViewerHelpers';
68+
import { createLineDiffCache } from '../utils/inlineDiff.js';
69+
import type { BeforeLineClass, AfterLineClass, CharHighlight } from '../utils/inlineDiff.js';
6670
import { setupDiffKeyboardNav } from '../utils/diffKeyboard';
6771
import { pathsMatch } from '../utils/diffModalHelpers';
6872
import CommentEditor from './CommentEditor.svelte';
@@ -125,6 +129,12 @@
125129
onDeleteComment,
126130
}: Props = $props();
127131
132+
// ==========================================================================
133+
// Inline diff cache (scoped to component instance)
134+
// ==========================================================================
135+
136+
const lineDiffCache = createLineDiffCache();
137+
128138
// ==========================================================================
129139
// Element refs
130140
// ==========================================================================
@@ -666,12 +676,13 @@
666676
// Search highlighting
667677
// ==========================================================================
668678
669-
/** A token segment that may be part of a search match */
679+
/** A token segment that may be part of a search match or char-level diff highlight */
670680
interface HighlightedSegment {
671681
content: string;
672682
color: string;
673683
isMatch: boolean;
674684
isCurrent: boolean;
685+
isCharChanged: boolean;
675686
}
676687
677688
/**
@@ -738,6 +749,7 @@
738749
color: t.color,
739750
isMatch: false,
740751
isCurrent: false,
752+
isCharChanged: false,
741753
}));
742754
}
743755
@@ -760,6 +772,7 @@
760772
color: token.color,
761773
isMatch: false,
762774
isCurrent: false,
775+
isCharChanged: false,
763776
});
764777
} else {
765778
// Token has matches - split at match boundaries
@@ -776,6 +789,7 @@
776789
color: token.color,
777790
isMatch: false,
778791
isCurrent: false,
792+
isCharChanged: false,
779793
});
780794
}
781795
@@ -785,6 +799,7 @@
785799
color: token.color,
786800
isMatch: true,
787801
isCurrent: match.isCurrent,
802+
isCharChanged: false,
788803
});
789804
790805
pos = matchEnd;
@@ -797,6 +812,7 @@
797812
color: token.color,
798813
isMatch: false,
799814
isCurrent: false,
815+
isCharChanged: false,
800816
});
801817
}
802818
}
@@ -808,15 +824,88 @@
808824
}
809825
810826
/**
811-
* Get highlighted token segments for a line, with search matches applied.
827+
* Apply character-level diff highlights to segments by splitting them at highlight boundaries.
828+
* Works similarly to applySearchHighlights — walks through segments tracking column position.
829+
*/
830+
function applyCharHighlights(
831+
segments: HighlightedSegment[],
832+
highlights: CharHighlight[]
833+
): HighlightedSegment[] {
834+
if (highlights.length === 0) return segments;
835+
836+
const result: HighlightedSegment[] = [];
837+
let charIndex = 0;
838+
839+
for (const segment of segments) {
840+
const segStart = charIndex;
841+
const segEnd = charIndex + segment.content.length;
842+
843+
// Find highlights that overlap with this segment
844+
const overlapping = highlights.filter(
845+
(h) => h.start < segEnd && h.end > segStart
846+
);
847+
848+
if (overlapping.length === 0) {
849+
result.push(segment);
850+
} else {
851+
let pos = 0; // Position within segment content
852+
853+
for (const hl of overlapping) {
854+
const hlStart = Math.max(0, hl.start - segStart);
855+
const hlEnd = Math.min(segment.content.length, hl.end - segStart);
856+
857+
// Add any content before the highlight
858+
if (pos < hlStart) {
859+
result.push({
860+
...segment,
861+
content: segment.content.slice(pos, hlStart),
862+
isCharChanged: false,
863+
});
864+
}
865+
866+
// Add the highlighted portion
867+
result.push({
868+
...segment,
869+
content: segment.content.slice(hlStart, hlEnd),
870+
isCharChanged: true,
871+
});
872+
873+
pos = hlEnd;
874+
}
875+
876+
// Add any remaining content after all highlights
877+
if (pos < segment.content.length) {
878+
result.push({
879+
...segment,
880+
content: segment.content.slice(pos),
881+
isCharChanged: false,
882+
});
883+
}
884+
}
885+
886+
charIndex = segEnd;
887+
}
888+
889+
return result;
890+
}
891+
892+
/**
893+
* Get highlighted token segments for a line, with search matches and char-level diff applied.
812894
*/
813895
function getHighlightedTokens(
814896
lineIndex: number,
815897
side: 'before' | 'after'
816898
): HighlightedSegment[] {
817899
const tokens = side === 'before' ? getBeforeTokens(lineIndex) : getAfterTokens(lineIndex);
818900
const matches = getSearchMatchesForLine(lineIndex, side);
819-
return applySearchHighlights(tokens, matches);
901+
let segments = applySearchHighlights(tokens, matches);
902+
903+
const charHL = getCharHighlightsForLine(side, lineIndex);
904+
if (charHL && charHL.length > 0) {
905+
segments = applyCharHighlights(segments, charHL);
906+
}
907+
908+
return segments;
820909
}
821910
822911
// ==========================================================================
@@ -832,6 +921,32 @@
832921
);
833922
}
834923
924+
function getLineClassForLine(side: 'before' | 'after', lineIndex: number): BeforeLineClass | AfterLineClass | null {
925+
return helperGetLineClass(
926+
side,
927+
lineIndex,
928+
beforeLineToAlignment,
929+
afterLineToAlignment,
930+
changedAlignments,
931+
beforeLines,
932+
afterLines,
933+
lineDiffCache
934+
);
935+
}
936+
937+
function getCharHighlightsForLine(side: 'before' | 'after', lineIndex: number): CharHighlight[] | null {
938+
return helperGetCharHighlights(
939+
side,
940+
lineIndex,
941+
beforeLineToAlignment,
942+
afterLineToAlignment,
943+
changedAlignments,
944+
beforeLines,
945+
afterLines,
946+
lineDiffCache
947+
);
948+
}
949+
835950
function isLineSelected(pane: 'before' | 'after', lineIndex: number): boolean {
836951
return helperIsLineSelected(pane, lineIndex, selectedLineRange);
837952
}
@@ -1760,15 +1875,17 @@
17601875
: { isStart: false, isEnd: false }}
17611876
{@const isInHoveredRange = isLineInHoveredRange('before', i)}
17621877
{@const isInFocusedHunk = isLineInFocusedHunk('before', i)}
1763-
{@const isChanged = showRangeMarkers && isLineInChangedAlignment('before', i)}
1878+
{@const lineClass = showRangeMarkers ? getLineClassForLine('before', i) : null}
1879+
{@const isChanged = lineClass !== null && lineClass !== 'unchanged'}
17641880
<!-- svelte-ignore a11y_no_static_element_interactions -->
17651881
<div
17661882
class="line"
17671883
class:range-start={boundary.isStart}
17681884
class:range-end={boundary.isEnd}
17691885
class:range-hovered={isInHoveredRange}
17701886
class:range-focused={isInFocusedHunk}
1771-
class:content-changed={isChanged}
1887+
class:content-changed={isChanged && lineClass !== 'modified'}
1888+
class:diff-modified={lineClass === 'modified'}
17721889
onmouseenter={() => handleLineMouseEnter('before', i)}
17731890
onmouseleave={handleLineMouseLeave}
17741891
>
@@ -1778,6 +1895,7 @@
17781895
style="color: {segment.color}"
17791896
class:search-match={segment.isMatch && !segment.isCurrent}
17801897
class:search-current={segment.isCurrent}
1898+
class:char-changed={segment.isCharChanged}
17811899
>
17821900
{segment.content}
17831901
</span>
@@ -1851,6 +1969,7 @@
18511969
style="color: {segment.color}"
18521970
class:search-match={segment.isMatch && !segment.isCurrent}
18531971
class:search-current={segment.isCurrent}
1972+
class:char-changed={segment.isCharChanged}
18541973
>
18551974
{segment.content}
18561975
</span>
@@ -1927,7 +2046,8 @@
19272046
: { isStart: false, isEnd: false }}
19282047
{@const isInHoveredRange = isLineInHoveredRange('after', i)}
19292048
{@const isInFocusedHunk = isLineInFocusedHunk('after', i)}
1930-
{@const isChanged = showRangeMarkers && isLineInChangedAlignment('after', i)}
2049+
{@const lineClass = showRangeMarkers ? getLineClassForLine('after', i) : null}
2050+
{@const isChanged = lineClass !== null && lineClass !== 'unchanged'}
19312051
{@const isSelected = isLineSelected('after', i)}
19322052
<!-- svelte-ignore a11y_no_static_element_interactions -->
19332053
<div
@@ -1936,7 +2056,8 @@
19362056
class:range-end={boundary.isEnd}
19372057
class:range-hovered={isInHoveredRange}
19382058
class:range-focused={isInFocusedHunk}
1939-
class:content-changed={isChanged}
2059+
class:content-changed={isChanged && lineClass !== 'modified'}
2060+
class:diff-modified={lineClass === 'modified'}
19402061
class:line-selected={isSelected}
19412062
onmouseenter={() => handleLineMouseEnter('after', i)}
19422063
onmouseleave={handleLineMouseLeave}
@@ -1948,6 +2069,7 @@
19482069
style="color: {segment.color}"
19492070
class:search-match={segment.isMatch && !segment.isCurrent}
19502071
class:search-current={segment.isCurrent}
2072+
class:char-changed={segment.isCharChanged}
19512073
>
19522074
{segment.content}
19532075
</span>
@@ -2553,6 +2675,15 @@
25532675
background-color: var(--diff-added-bg);
25542676
}
25552677
2678+
.line.diff-modified {
2679+
background-color: var(--diff-modified-bg);
2680+
}
2681+
2682+
.char-changed {
2683+
background-color: var(--diff-modified-inline-bg);
2684+
border-radius: 2px;
2685+
}
2686+
25562687
/* Range boundary markers */
25572688
.line.range-start::before {
25582689
content: '';

0 commit comments

Comments
 (0)