Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/staged/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
--diff-added-bg: rgba(63, 185, 80, 0.08);
--diff-removed-bg: rgba(248, 81, 73, 0.08);
--diff-changed-bg: rgba(255, 255, 255, 0.04);
--diff-modified-bg: rgba(227, 179, 65, 0.08);
--diff-modified-inline-bg: rgba(227, 179, 65, 0.25);
--diff-range-border: #524d58;
--diff-comment-highlight: rgba(88, 166, 255, 0.5);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export default class BranchCardSessionManager {
/** True when a new session will be queued rather than started immediately. */
willQueue = $derived(
!this.getTimeline() || // provisioning — no timeline yet
this.hasRunningSession // another session is active
this.hasRunningSession || // another session is active
this.isSessionStartPending // a session start is already in flight
);

/** True when new session actions (new commit, note, review) should be disabled. */
Expand Down
145 changes: 138 additions & 7 deletions packages/diff-viewer/src/lib/components/DiffViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
isLineInChangedAlignment as helperIsLineInChangedAlignment,
isLineInIndexedRange,
isLineSelected as helperIsLineSelected,
getLineClass as helperGetLineClass,
getCharHighlights as helperGetCharHighlights,
buildLineCommentEditorLayout,
buildLineSelectionToolbarLayout,
buildRangeCommentEditorLayout,
Expand All @@ -63,6 +65,8 @@
normalizeLineSelection,
resolveLineSelectionToolbarLeft,
} from '../utils/diffViewerHelpers';
import { createLineDiffCache } from '../utils/inlineDiff.js';
import type { BeforeLineClass, AfterLineClass, CharHighlight } from '../utils/inlineDiff.js';
import { setupDiffKeyboardNav } from '../utils/diffKeyboard';
import { pathsMatch } from '../utils/diffModalHelpers';
import CommentEditor from './CommentEditor.svelte';
Expand Down Expand Up @@ -125,6 +129,12 @@
onDeleteComment,
}: Props = $props();

// ==========================================================================
// Inline diff cache (scoped to component instance)
// ==========================================================================

const lineDiffCache = createLineDiffCache();

// ==========================================================================
// Element refs
// ==========================================================================
Expand Down Expand Up @@ -666,12 +676,13 @@
// Search highlighting
// ==========================================================================

/** A token segment that may be part of a search match */
/** A token segment that may be part of a search match or char-level diff highlight */
interface HighlightedSegment {
content: string;
color: string;
isMatch: boolean;
isCurrent: boolean;
isCharChanged: boolean;
}

/**
Expand Down Expand Up @@ -738,6 +749,7 @@
color: t.color,
isMatch: false,
isCurrent: false,
isCharChanged: false,
}));
}

Expand All @@ -760,6 +772,7 @@
color: token.color,
isMatch: false,
isCurrent: false,
isCharChanged: false,
});
} else {
// Token has matches - split at match boundaries
Expand All @@ -776,6 +789,7 @@
color: token.color,
isMatch: false,
isCurrent: false,
isCharChanged: false,
});
}

Expand All @@ -785,6 +799,7 @@
color: token.color,
isMatch: true,
isCurrent: match.isCurrent,
isCharChanged: false,
});

pos = matchEnd;
Expand All @@ -797,6 +812,7 @@
color: token.color,
isMatch: false,
isCurrent: false,
isCharChanged: false,
});
}
}
Expand All @@ -808,15 +824,88 @@
}

/**
* Get highlighted token segments for a line, with search matches applied.
* Apply character-level diff highlights to segments by splitting them at highlight boundaries.
* Works similarly to applySearchHighlights — walks through segments tracking column position.
*/
function applyCharHighlights(
segments: HighlightedSegment[],
highlights: CharHighlight[]
): HighlightedSegment[] {
if (highlights.length === 0) return segments;

const result: HighlightedSegment[] = [];
let charIndex = 0;

for (const segment of segments) {
const segStart = charIndex;
const segEnd = charIndex + segment.content.length;

// Find highlights that overlap with this segment
const overlapping = highlights.filter(
(h) => h.start < segEnd && h.end > segStart
);

if (overlapping.length === 0) {
result.push(segment);
} else {
let pos = 0; // Position within segment content

for (const hl of overlapping) {
const hlStart = Math.max(0, hl.start - segStart);
const hlEnd = Math.min(segment.content.length, hl.end - segStart);

// Add any content before the highlight
if (pos < hlStart) {
result.push({
...segment,
content: segment.content.slice(pos, hlStart),
isCharChanged: false,
});
}

// Add the highlighted portion
result.push({
...segment,
content: segment.content.slice(hlStart, hlEnd),
isCharChanged: true,
});

pos = hlEnd;
}

// Add any remaining content after all highlights
if (pos < segment.content.length) {
result.push({
...segment,
content: segment.content.slice(pos),
isCharChanged: false,
});
}
}

charIndex = segEnd;
}

return result;
}

/**
* Get highlighted token segments for a line, with search matches and char-level diff applied.
*/
function getHighlightedTokens(
lineIndex: number,
side: 'before' | 'after'
): HighlightedSegment[] {
const tokens = side === 'before' ? getBeforeTokens(lineIndex) : getAfterTokens(lineIndex);
const matches = getSearchMatchesForLine(lineIndex, side);
return applySearchHighlights(tokens, matches);
let segments = applySearchHighlights(tokens, matches);

const charHL = getCharHighlightsForLine(side, lineIndex);
if (charHL && charHL.length > 0) {
segments = applyCharHighlights(segments, charHL);
}

return segments;
}

// ==========================================================================
Expand All @@ -832,6 +921,32 @@
);
}

function getLineClassForLine(side: 'before' | 'after', lineIndex: number): BeforeLineClass | AfterLineClass | null {
return helperGetLineClass(
side,
lineIndex,
beforeLineToAlignment,
afterLineToAlignment,
changedAlignments,
beforeLines,
afterLines,
lineDiffCache
);
}

function getCharHighlightsForLine(side: 'before' | 'after', lineIndex: number): CharHighlight[] | null {
return helperGetCharHighlights(
side,
lineIndex,
beforeLineToAlignment,
afterLineToAlignment,
changedAlignments,
beforeLines,
afterLines,
lineDiffCache
);
}

function isLineSelected(pane: 'before' | 'after', lineIndex: number): boolean {
return helperIsLineSelected(pane, lineIndex, selectedLineRange);
}
Expand Down Expand Up @@ -1760,15 +1875,17 @@
: { isStart: false, isEnd: false }}
{@const isInHoveredRange = isLineInHoveredRange('before', i)}
{@const isInFocusedHunk = isLineInFocusedHunk('before', i)}
{@const isChanged = showRangeMarkers && isLineInChangedAlignment('before', i)}
{@const lineClass = showRangeMarkers ? getLineClassForLine('before', i) : null}
{@const isChanged = lineClass !== null && lineClass !== 'unchanged'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="line"
class:range-start={boundary.isStart}
class:range-end={boundary.isEnd}
class:range-hovered={isInHoveredRange}
class:range-focused={isInFocusedHunk}
class:content-changed={isChanged}
class:content-changed={isChanged && lineClass !== 'modified'}
class:diff-modified={lineClass === 'modified'}
onmouseenter={() => handleLineMouseEnter('before', i)}
onmouseleave={handleLineMouseLeave}
>
Expand All @@ -1778,6 +1895,7 @@
style="color: {segment.color}"
class:search-match={segment.isMatch && !segment.isCurrent}
class:search-current={segment.isCurrent}
class:char-changed={segment.isCharChanged}
>
{segment.content}
</span>
Expand Down Expand Up @@ -1851,6 +1969,7 @@
style="color: {segment.color}"
class:search-match={segment.isMatch && !segment.isCurrent}
class:search-current={segment.isCurrent}
class:char-changed={segment.isCharChanged}
>
{segment.content}
</span>
Expand Down Expand Up @@ -1927,7 +2046,8 @@
: { isStart: false, isEnd: false }}
{@const isInHoveredRange = isLineInHoveredRange('after', i)}
{@const isInFocusedHunk = isLineInFocusedHunk('after', i)}
{@const isChanged = showRangeMarkers && isLineInChangedAlignment('after', i)}
{@const lineClass = showRangeMarkers ? getLineClassForLine('after', i) : null}
{@const isChanged = lineClass !== null && lineClass !== 'unchanged'}
{@const isSelected = isLineSelected('after', i)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
Expand All @@ -1936,7 +2056,8 @@
class:range-end={boundary.isEnd}
class:range-hovered={isInHoveredRange}
class:range-focused={isInFocusedHunk}
class:content-changed={isChanged}
class:content-changed={isChanged && lineClass !== 'modified'}
class:diff-modified={lineClass === 'modified'}
class:line-selected={isSelected}
onmouseenter={() => handleLineMouseEnter('after', i)}
onmouseleave={handleLineMouseLeave}
Expand All @@ -1948,6 +2069,7 @@
style="color: {segment.color}"
class:search-match={segment.isMatch && !segment.isCurrent}
class:search-current={segment.isCurrent}
class:char-changed={segment.isCharChanged}
>
{segment.content}
</span>
Expand Down Expand Up @@ -2553,6 +2675,15 @@
background-color: var(--diff-added-bg);
}

.line.diff-modified {
background-color: var(--diff-modified-bg);
}

.char-changed {
background-color: var(--diff-modified-inline-bg);
border-radius: 2px;
}

/* Range boundary markers */
.line.range-start::before {
content: '';
Expand Down
Loading