diff --git a/src/components/code-diff/code-diff.scss b/src/components/code-diff/code-diff.scss
index 36843d886e..393192f862 100644
--- a/src/components/code-diff/code-diff.scss
+++ b/src/components/code-diff/code-diff.scss
@@ -110,6 +110,42 @@
display: flex;
align-items: center;
gap: 0.25rem;
+ // Pushes the toolbar to the right edge regardless of whether the labels
+ // block is rendered (it isn't in split mode).
+ margin-left: auto;
+}
+
+// ─── Split-mode column labels ────────────────────────────────────────
+// Renders below the actions toolbar, mirroring the 4-cell layout of
+// `.diff-line--split` so the `oldHeading` / `newHeading` text sits
+// directly above the columns they describe.
+.diff-header__column-labels {
+ display: flex;
+ align-items: stretch;
+ border-bottom: 1px solid var(--diff-border-color);
+ @include mixins.font-family(sans-serif);
+ font-size: 0.875rem;
+}
+
+.diff-header__column-gutter {
+ width: var(--limel-line-number-min-width);
+ flex-shrink: 0;
+ background: var(--diff-gutter-bg);
+}
+
+.diff-header__column-label {
+ flex: 1;
+ min-width: 0;
+ padding: 0.375rem 0.75rem;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &--old {
+ // Divider matches the split divider between the diff columns below.
+ border-right: 1px solid var(--diff-split-divider-color);
+ }
}
.search-toggle--active {
@@ -158,6 +194,10 @@
flex-grow: 1;
}
+ limel-button-group {
+ flex-shrink: 0;
+ }
+
&__count {
color: var(--diff-collapsed-text-color);
white-space: nowrap;
diff --git a/src/components/code-diff/code-diff.tsx b/src/components/code-diff/code-diff.tsx
index e32de84496..233fa811b3 100644
--- a/src/components/code-diff/code-diff.tsx
+++ b/src/components/code-diff/code-diff.tsx
@@ -10,9 +10,16 @@ import {
SplitDiffLine,
} from './types';
import { ActionBarItem } from '../action-bar/action-bar.types';
+import { Button } from '../button/button.types';
import { buildSplitLines, computeDiff, normalizeForDiff } from './diff-engine';
import { tokenize, SyntaxToken } from './syntax-highlighter';
-import { buildSearchRegex, navigateMatchIndex } from './search-utils';
+import {
+ buildSearchRegex,
+ navigateMatchIndex,
+ pickDefaultScope,
+ lineMatchesScope,
+ SearchScope,
+} from './search-utils';
import {
extractRemovedContent,
extractRemovedContentFromSplit,
@@ -140,12 +147,15 @@ export class CodeDiff {
@State()
private currentMatchIndex: number = 0;
+ @State()
+ private searchScope: SearchScope = 'removed';
+
private focusedRowIndex: number = -1;
private normalizedOldText: string = '';
/**
* Render-time counter that increments for each search match
- * found while rendering removed lines. Used to determine which
+ * found while rendering in-scope lines. Used to determine which
* match is the "current" one for navigation highlighting.
*/
private searchMatchCounter: number = 0;
@@ -156,10 +166,11 @@ export class CodeDiff {
private totalSearchMatches: number = 0;
/**
- * Whether the current render is inside a removed line,
- * so search highlighting knows when to activate.
+ * Whether the current line being rendered participates in the
+ * active search scope, so search highlighting knows when to
+ * activate.
*/
- private isRenderingRemovedLine: boolean = false;
+ private isRenderingSearchableLine: boolean = false;
/**
* Cached search regex for the current render pass.
@@ -359,13 +370,16 @@ export class CodeDiff {
const { additions, deletions } = this.diffResult;
const hasDiff = additions > 0 || deletions > 0;
+ const isSplit = this.layout === 'split';
- return (
+ return [
- );
+ ,
+ isSplit && (
+ // In split mode the labels live in their own row below the
+ // actions toolbar, mirroring the 4-cell layout of `.diff-line--split`
+ // so each label sits above the column it describes.
+
+
+
+ {oldHeading}
+
+
+
+ {newHeading}
+
+
+ ),
+ ];
}
private renderCopyButton() {
@@ -437,6 +466,14 @@ export class CodeDiff {
return (
+ ) =>
+ this.onScopeChange(e.detail)
+ }
+ />
) {
const { value } = event.detail;
if (value === 'prev') {
@@ -769,8 +840,9 @@ export class CodeDiff {
}
private renderContent(line: DiffLine) {
- this.isRenderingRemovedLine =
- line.type === 'removed' && this.searchTerm.length > 0;
+ this.isRenderingSearchableLine =
+ this.searchTerm.length > 0 &&
+ lineMatchesScope(line.type, this.searchScope);
if (!line.segments || line.segments.length === 0) {
return this.renderSyntaxTokens(line.content);
@@ -815,7 +887,7 @@ export class CodeDiff {
}
private renderSearchableText(text: string): any {
- if (!this.isRenderingRemovedLine || !this.activeSearchRegex) {
+ if (!this.isRenderingSearchableLine || !this.activeSearchRegex) {
return text;
}
diff --git a/src/components/code-diff/search-utils.spec.ts b/src/components/code-diff/search-utils.spec.ts
index fcc63c9a80..1fa5f881ee 100644
--- a/src/components/code-diff/search-utils.spec.ts
+++ b/src/components/code-diff/search-utils.spec.ts
@@ -3,6 +3,8 @@ import {
escapeRegex,
buildSearchRegex,
navigateMatchIndex,
+ pickDefaultScope,
+ lineMatchesScope,
} from './search-utils';
describe('escapeRegex', () => {
@@ -66,3 +68,65 @@ describe('navigateMatchIndex', () => {
expect(navigateMatchIndex(0, -1, 5)).toBe(4);
});
});
+
+describe('pickDefaultScope', () => {
+ it('returns "removed" when there are deletions', () => {
+ expect(pickDefaultScope({ additions: 0, deletions: 3 })).toBe(
+ 'removed'
+ );
+ });
+
+ it('returns "removed" when both additions and deletions exist', () => {
+ expect(pickDefaultScope({ additions: 5, deletions: 2 })).toBe(
+ 'removed'
+ );
+ });
+
+ it('returns "added" when only additions exist', () => {
+ expect(pickDefaultScope({ additions: 4, deletions: 0 })).toBe('added');
+ });
+});
+
+describe('lineMatchesScope', () => {
+ describe('scope: removed', () => {
+ it('matches removed lines', () => {
+ expect(lineMatchesScope('removed', 'removed')).toBe(true);
+ });
+
+ it('does not match added lines', () => {
+ expect(lineMatchesScope('added', 'removed')).toBe(false);
+ });
+
+ it('does not match context lines', () => {
+ expect(lineMatchesScope('context', 'removed')).toBe(false);
+ });
+ });
+
+ describe('scope: added', () => {
+ it('matches added lines', () => {
+ expect(lineMatchesScope('added', 'added')).toBe(true);
+ });
+
+ it('does not match removed lines', () => {
+ expect(lineMatchesScope('removed', 'added')).toBe(false);
+ });
+
+ it('does not match context lines', () => {
+ expect(lineMatchesScope('context', 'added')).toBe(false);
+ });
+ });
+
+ describe('scope: changed', () => {
+ it('matches removed lines', () => {
+ expect(lineMatchesScope('removed', 'changed')).toBe(true);
+ });
+
+ it('matches added lines', () => {
+ expect(lineMatchesScope('added', 'changed')).toBe(true);
+ });
+
+ it('does not match context lines', () => {
+ expect(lineMatchesScope('context', 'changed')).toBe(false);
+ });
+ });
+});
diff --git a/src/components/code-diff/search-utils.ts b/src/components/code-diff/search-utils.ts
index 129f6e5e35..8c9f466950 100644
--- a/src/components/code-diff/search-utils.ts
+++ b/src/components/code-diff/search-utils.ts
@@ -2,6 +2,13 @@
* Pure utility functions for search-within-diff functionality.
*/
+import type { DiffLine } from './types';
+
+/**
+ * The line types that the in-diff search operates on.
+ */
+export type SearchScope = 'removed' | 'added' | 'changed';
+
/**
* Escape special regex characters in a search term so it can
* be used as a literal pattern in a RegExp constructor.
@@ -48,3 +55,47 @@ export function navigateMatchIndex(
return (currentIndex + direction + total) % total;
}
+
+/**
+ * Pick the default `SearchScope` to use when the search panel opens.
+ * Falls back to `'added'` when there are no removed lines, so the
+ * panel never opens with a scope that has zero matches.
+ *
+ * @param stats - the current diff statistics
+ * @param stats.additions - number of added lines in the diff
+ * @param stats.deletions - number of removed lines in the diff
+ * @returns the scope that should be active when the panel opens
+ */
+export function pickDefaultScope(stats: {
+ additions: number;
+ deletions: number;
+}): SearchScope {
+ if (stats.deletions > 0) {
+ return 'removed';
+ }
+
+ return 'added';
+}
+
+/**
+ * Whether a line of the given type participates in the active
+ * `SearchScope`. Context lines are never included.
+ *
+ * @param lineType - the type of the diff line being considered
+ * @param scope - the active search scope
+ * @returns true when the line participates in the scope, false otherwise
+ */
+export function lineMatchesScope(
+ lineType: DiffLine['type'],
+ scope: SearchScope
+): boolean {
+ if (lineType === 'context') {
+ return false;
+ }
+
+ if (scope === 'changed') {
+ return true;
+ }
+
+ return lineType === scope;
+}
diff --git a/src/translations/da.ts b/src/translations/da.ts
index 44985d86ce..124c5f4e03 100644
--- a/src/translations/da.ts
+++ b/src/translations/da.ts
@@ -87,7 +87,11 @@ Du kan fortsætte med at blokere billeder (e-mailen kan se ufuldstændig ud) ell
'code-diff.copied': 'Kopieret!',
'code-diff.copied-to-clipboard': 'Kopieret til udklipsholder',
'code-diff.copy-change': 'Kopiér gammel version af denne ændring',
- 'code-diff.search': 'Søg i fjernede linjer',
+ 'code-diff.search': 'Søg',
+ 'code-diff.search-scope': 'Søgeområde',
+ 'code-diff.search-scope-removed': 'Fjernede linjer',
+ 'code-diff.search-scope-added': 'Tilføjede linjer',
+ 'code-diff.search-scope-changed': 'Ændrede linjer',
'code-diff.previous-match': 'Forrige match',
'code-diff.next-match': 'Næste match',
'code-diff.close-search': 'Luk søgning',
diff --git a/src/translations/de.ts b/src/translations/de.ts
index 4a903cb21f..87377095da 100644
--- a/src/translations/de.ts
+++ b/src/translations/de.ts
@@ -88,7 +88,11 @@ Sie können Bilder weiterhin blockieren (die E-Mail kann unvollständig aussehen
'code-diff.copied': 'Kopiert!',
'code-diff.copied-to-clipboard': 'In die Zwischenablage kopiert',
'code-diff.copy-change': 'Alte Version dieser Änderung kopieren',
- 'code-diff.search': 'Entfernte Zeilen durchsuchen',
+ 'code-diff.search': 'Suchen',
+ 'code-diff.search-scope': 'Suchbereich',
+ 'code-diff.search-scope-removed': 'Entfernte Zeilen',
+ 'code-diff.search-scope-added': 'Hinzugefügte Zeilen',
+ 'code-diff.search-scope-changed': 'Geänderte Zeilen',
'code-diff.previous-match': 'Vorherige Übereinstimmung',
'code-diff.next-match': 'Nächste Übereinstimmung',
'code-diff.close-search': 'Suche schließen',
diff --git a/src/translations/en.ts b/src/translations/en.ts
index ffdee04af6..9b96c8e132 100644
--- a/src/translations/en.ts
+++ b/src/translations/en.ts
@@ -86,7 +86,11 @@ You can keep images blocked (the email may look incomplete), or load them if you
'code-diff.copied': 'Copied!',
'code-diff.copied-to-clipboard': 'Copied to clipboard',
'code-diff.copy-change': 'Copy old version of this change',
- 'code-diff.search': 'Search removed lines',
+ 'code-diff.search': 'Search',
+ 'code-diff.search-scope': 'Search scope',
+ 'code-diff.search-scope-removed': 'Removed lines',
+ 'code-diff.search-scope-added': 'Added lines',
+ 'code-diff.search-scope-changed': 'Changed lines',
'code-diff.previous-match': 'Previous match',
'code-diff.next-match': 'Next match',
'code-diff.close-search': 'Close search',
diff --git a/src/translations/fi.ts b/src/translations/fi.ts
index 3c95a2d4fc..412bb05b6a 100644
--- a/src/translations/fi.ts
+++ b/src/translations/fi.ts
@@ -87,7 +87,11 @@ Voit pitää kuvat estettyinä (sähköposti voi näyttää puutteelliselta) tai
'code-diff.copied': 'Kopioitu!',
'code-diff.copied-to-clipboard': 'Kopioitu leikepöydälle',
'code-diff.copy-change': 'Kopioi tämän muutoksen vanha versio',
- 'code-diff.search': 'Hae poistetuista riveistä',
+ 'code-diff.search': 'Hae',
+ 'code-diff.search-scope': 'Hakualue',
+ 'code-diff.search-scope-removed': 'Poistetut rivit',
+ 'code-diff.search-scope-added': 'Lisätyt rivit',
+ 'code-diff.search-scope-changed': 'Muuttuneet rivit',
'code-diff.previous-match': 'Edellinen osuma',
'code-diff.next-match': 'Seuraava osuma',
'code-diff.close-search': 'Sulje haku',
diff --git a/src/translations/fr.ts b/src/translations/fr.ts
index a32caa7c54..c91753e84c 100644
--- a/src/translations/fr.ts
+++ b/src/translations/fr.ts
@@ -90,7 +90,11 @@ Vous pouvez laisser les images bloquées (l'e-mail peut sembler incomplet) ou le
'code-diff.copied-to-clipboard': 'Copié dans le presse-papiers',
'code-diff.copy-change':
'Copier l\u2019ancienne version de cette modification',
- 'code-diff.search': 'Rechercher dans les lignes supprimées',
+ 'code-diff.search': 'Rechercher',
+ 'code-diff.search-scope': 'Portée de la recherche',
+ 'code-diff.search-scope-removed': 'Lignes supprimées',
+ 'code-diff.search-scope-added': 'Lignes ajoutées',
+ 'code-diff.search-scope-changed': 'Lignes modifiées',
'code-diff.previous-match': 'Correspondance précédente',
'code-diff.next-match': 'Correspondance suivante',
'code-diff.close-search': 'Fermer la recherche',
diff --git a/src/translations/nl.ts b/src/translations/nl.ts
index e09e8a7546..25a23636fe 100644
--- a/src/translations/nl.ts
+++ b/src/translations/nl.ts
@@ -87,7 +87,11 @@ Je kunt afbeeldingen geblokkeerd houden (de e-mail kan er onvolledig uitzien) of
'code-diff.copied': 'Gekopieerd!',
'code-diff.copied-to-clipboard': 'Gekopieerd naar klembord',
'code-diff.copy-change': 'Oude versie van deze wijziging kopiëren',
- 'code-diff.search': 'Zoeken in verwijderde regels',
+ 'code-diff.search': 'Zoeken',
+ 'code-diff.search-scope': 'Zoekbereik',
+ 'code-diff.search-scope-removed': 'Verwijderde regels',
+ 'code-diff.search-scope-added': 'Toegevoegde regels',
+ 'code-diff.search-scope-changed': 'Gewijzigde regels',
'code-diff.previous-match': 'Vorige overeenkomst',
'code-diff.next-match': 'Volgende overeenkomst',
'code-diff.close-search': 'Zoeken sluiten',
diff --git a/src/translations/no.ts b/src/translations/no.ts
index 6f328a4f30..ed79c0dfb2 100644
--- a/src/translations/no.ts
+++ b/src/translations/no.ts
@@ -87,7 +87,11 @@ Du kan fortsette å blokkere bilder (e-posten kan se ufullstendig ut), eller las
'code-diff.copied': 'Kopiert!',
'code-diff.copied-to-clipboard': 'Kopiert til utklippstavlen',
'code-diff.copy-change': 'Kopier gammel versjon av denne endringen',
- 'code-diff.search': 'Søk i fjernede linjer',
+ 'code-diff.search': 'Søk',
+ 'code-diff.search-scope': 'Søkeområde',
+ 'code-diff.search-scope-removed': 'Fjernede linjer',
+ 'code-diff.search-scope-added': 'Tillagte linjer',
+ 'code-diff.search-scope-changed': 'Endrede linjer',
'code-diff.previous-match': 'Forrige treff',
'code-diff.next-match': 'Neste treff',
'code-diff.close-search': 'Lukk søk',
diff --git a/src/translations/sv.ts b/src/translations/sv.ts
index 96fce8337e..6ca7b8975e 100644
--- a/src/translations/sv.ts
+++ b/src/translations/sv.ts
@@ -87,7 +87,11 @@ Du kan fortsätta blockera bilder (e-posten kan se ofullständig ut) eller ladda
'code-diff.copied': 'Kopierat!',
'code-diff.copied-to-clipboard': 'Kopierat till urklipp',
'code-diff.copy-change': 'Kopiera den gamla versionen av ändringen',
- 'code-diff.search': 'Sök i borttagna rader',
+ 'code-diff.search': 'Sök',
+ 'code-diff.search-scope': 'Sökområde',
+ 'code-diff.search-scope-removed': 'Borttagna rader',
+ 'code-diff.search-scope-added': 'Tillagda rader',
+ 'code-diff.search-scope-changed': 'Ändrade rader',
'code-diff.previous-match': 'Föregående träff',
'code-diff.next-match': 'Nästa träff',
'code-diff.close-search': 'Stäng sökning',