|
54 | 54 | isLineInChangedAlignment as helperIsLineInChangedAlignment, |
55 | 55 | isLineInIndexedRange, |
56 | 56 | isLineSelected as helperIsLineSelected, |
| 57 | + getLineClass as helperGetLineClass, |
| 58 | + getCharHighlights as helperGetCharHighlights, |
57 | 59 | buildLineCommentEditorLayout, |
58 | 60 | buildLineSelectionToolbarLayout, |
59 | 61 | buildRangeCommentEditorLayout, |
|
63 | 65 | normalizeLineSelection, |
64 | 66 | resolveLineSelectionToolbarLeft, |
65 | 67 | } from '../utils/diffViewerHelpers'; |
| 68 | + import { createLineDiffCache } from '../utils/inlineDiff.js'; |
| 69 | + import type { BeforeLineClass, AfterLineClass, CharHighlight } from '../utils/inlineDiff.js'; |
66 | 70 | import { setupDiffKeyboardNav } from '../utils/diffKeyboard'; |
67 | 71 | import { pathsMatch } from '../utils/diffModalHelpers'; |
68 | 72 | import CommentEditor from './CommentEditor.svelte'; |
|
125 | 129 | onDeleteComment, |
126 | 130 | }: Props = $props(); |
127 | 131 |
|
| 132 | + // ========================================================================== |
| 133 | + // Inline diff cache (scoped to component instance) |
| 134 | + // ========================================================================== |
| 135 | +
|
| 136 | + const lineDiffCache = createLineDiffCache(); |
| 137 | +
|
128 | 138 | // ========================================================================== |
129 | 139 | // Element refs |
130 | 140 | // ========================================================================== |
|
666 | 676 | // Search highlighting |
667 | 677 | // ========================================================================== |
668 | 678 |
|
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 */ |
670 | 680 | interface HighlightedSegment { |
671 | 681 | content: string; |
672 | 682 | color: string; |
673 | 683 | isMatch: boolean; |
674 | 684 | isCurrent: boolean; |
| 685 | + isCharChanged: boolean; |
675 | 686 | } |
676 | 687 |
|
677 | 688 | /** |
|
738 | 749 | color: t.color, |
739 | 750 | isMatch: false, |
740 | 751 | isCurrent: false, |
| 752 | + isCharChanged: false, |
741 | 753 | })); |
742 | 754 | } |
743 | 755 |
|
|
760 | 772 | color: token.color, |
761 | 773 | isMatch: false, |
762 | 774 | isCurrent: false, |
| 775 | + isCharChanged: false, |
763 | 776 | }); |
764 | 777 | } else { |
765 | 778 | // Token has matches - split at match boundaries |
|
776 | 789 | color: token.color, |
777 | 790 | isMatch: false, |
778 | 791 | isCurrent: false, |
| 792 | + isCharChanged: false, |
779 | 793 | }); |
780 | 794 | } |
781 | 795 |
|
|
785 | 799 | color: token.color, |
786 | 800 | isMatch: true, |
787 | 801 | isCurrent: match.isCurrent, |
| 802 | + isCharChanged: false, |
788 | 803 | }); |
789 | 804 |
|
790 | 805 | pos = matchEnd; |
|
797 | 812 | color: token.color, |
798 | 813 | isMatch: false, |
799 | 814 | isCurrent: false, |
| 815 | + isCharChanged: false, |
800 | 816 | }); |
801 | 817 | } |
802 | 818 | } |
|
808 | 824 | } |
809 | 825 |
|
810 | 826 | /** |
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. |
812 | 894 | */ |
813 | 895 | function getHighlightedTokens( |
814 | 896 | lineIndex: number, |
815 | 897 | side: 'before' | 'after' |
816 | 898 | ): HighlightedSegment[] { |
817 | 899 | const tokens = side === 'before' ? getBeforeTokens(lineIndex) : getAfterTokens(lineIndex); |
818 | 900 | 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; |
820 | 909 | } |
821 | 910 |
|
822 | 911 | // ========================================================================== |
|
832 | 921 | ); |
833 | 922 | } |
834 | 923 |
|
| 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 | +
|
835 | 950 | function isLineSelected(pane: 'before' | 'after', lineIndex: number): boolean { |
836 | 951 | return helperIsLineSelected(pane, lineIndex, selectedLineRange); |
837 | 952 | } |
|
1760 | 1875 | : { isStart: false, isEnd: false }} |
1761 | 1876 | {@const isInHoveredRange = isLineInHoveredRange('before', i)} |
1762 | 1877 | {@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'} |
1764 | 1880 | <!-- svelte-ignore a11y_no_static_element_interactions --> |
1765 | 1881 | <div |
1766 | 1882 | class="line" |
1767 | 1883 | class:range-start={boundary.isStart} |
1768 | 1884 | class:range-end={boundary.isEnd} |
1769 | 1885 | class:range-hovered={isInHoveredRange} |
1770 | 1886 | class:range-focused={isInFocusedHunk} |
1771 | | - class:content-changed={isChanged} |
| 1887 | + class:content-changed={isChanged && lineClass !== 'modified'} |
| 1888 | + class:diff-modified={lineClass === 'modified'} |
1772 | 1889 | onmouseenter={() => handleLineMouseEnter('before', i)} |
1773 | 1890 | onmouseleave={handleLineMouseLeave} |
1774 | 1891 | > |
|
1778 | 1895 | style="color: {segment.color}" |
1779 | 1896 | class:search-match={segment.isMatch && !segment.isCurrent} |
1780 | 1897 | class:search-current={segment.isCurrent} |
| 1898 | + class:char-changed={segment.isCharChanged} |
1781 | 1899 | > |
1782 | 1900 | {segment.content} |
1783 | 1901 | </span> |
|
1851 | 1969 | style="color: {segment.color}" |
1852 | 1970 | class:search-match={segment.isMatch && !segment.isCurrent} |
1853 | 1971 | class:search-current={segment.isCurrent} |
| 1972 | + class:char-changed={segment.isCharChanged} |
1854 | 1973 | > |
1855 | 1974 | {segment.content} |
1856 | 1975 | </span> |
|
1927 | 2046 | : { isStart: false, isEnd: false }} |
1928 | 2047 | {@const isInHoveredRange = isLineInHoveredRange('after', i)} |
1929 | 2048 | {@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'} |
1931 | 2051 | {@const isSelected = isLineSelected('after', i)} |
1932 | 2052 | <!-- svelte-ignore a11y_no_static_element_interactions --> |
1933 | 2053 | <div |
|
1936 | 2056 | class:range-end={boundary.isEnd} |
1937 | 2057 | class:range-hovered={isInHoveredRange} |
1938 | 2058 | class:range-focused={isInFocusedHunk} |
1939 | | - class:content-changed={isChanged} |
| 2059 | + class:content-changed={isChanged && lineClass !== 'modified'} |
| 2060 | + class:diff-modified={lineClass === 'modified'} |
1940 | 2061 | class:line-selected={isSelected} |
1941 | 2062 | onmouseenter={() => handleLineMouseEnter('after', i)} |
1942 | 2063 | onmouseleave={handleLineMouseLeave} |
|
1948 | 2069 | style="color: {segment.color}" |
1949 | 2070 | class:search-match={segment.isMatch && !segment.isCurrent} |
1950 | 2071 | class:search-current={segment.isCurrent} |
| 2072 | + class:char-changed={segment.isCharChanged} |
1951 | 2073 | > |
1952 | 2074 | {segment.content} |
1953 | 2075 | </span> |
|
2553 | 2675 | background-color: var(--diff-added-bg); |
2554 | 2676 | } |
2555 | 2677 |
|
| 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 | +
|
2556 | 2687 | /* Range boundary markers */ |
2557 | 2688 | .line.range-start::before { |
2558 | 2689 | content: ''; |
|
0 commit comments