GrDiff
Берут, когда Журнал аудита.
Обзор
Когда брать
- Журнал аудита: две версии записи рядом, видно ровно то, что правили.
- Ревизии документа: «чем эта отличается от предыдущей».
- Конфиги двух окружений: прод против стейджа, построчно.
- Ответ бэкенда с готовым диффом: приходит в
hunks, считать заново нечего.
Значение не обязано быть строкой: объект сериализуется устойчивым порядком ключей. Без этого два объекта с одинаковым содержимым и разным порядком дали бы выдуманные различия — правку там, где её не было.
Когда взять другое
| Задача | Компонент |
|---|---|
| Показать код без сравнения | GrCodeBlock |
| Дать код править | GrCodeEditor |
Обойти чужой unknown с раскрытием веток | GrJsonViewer (ядро) |
| Принять или отклонить блок изменений | ничего: это резолвер конфликтов, другой компонент |
Примеры 3 у каждого — живое демо и его код
Запись журнала аудита
Записи — объекты, а не строки: дифф сериализует их устойчивым порядком ключей, поэтому перемешанные ключи стороны «после» не рождают выдуманных правок — видно только то, что меняли.
mode="split"ставит обе версии рядом, а слотsummaryформулирует счёт для журнала.Subscription sub_4F2K · Northwind TradersAudit log · last three changes Plan upgraded by Mina Ortiz · 3 lines out, 7 in{{удалено "discount": null,добавлено "discount": {удалено "plan": "Team",добавлено "code": "UPGRADE10",добавлено "percent": 10,добавлено "until": "2026-12-31"добавлено },добавлено "plan": "Business","price": {"price": {удалено "amount": 588,добавлено "amount": 1188,"currency": "EUR","currency": "EUR","period": "month""period": "month"},},Ревизия шаблона письма
Прозу сравнивают как
textсwrap, а внутри изменённой строки отмечены изменённые слова — «7 days» → «14 days» читается одной правкой, а не новым абзацем. Раскладка переключается междуunifiedиsplit, а слотrow-actionsполучает каждую строку: здесь рецензент помечает строки для обсуждения перед утверждением.«Payment failed» email · revision 13Edited by Lena Novak · sent to every customer once approvedСтрок добавлено: 3, удалено: 31удаленоSubject: Your payment for {{invoice.number}} did not go through1добавленоSubject: Action needed: payment for {{invoice.number}} failed2233Hi {{customer.name}},445удаленоWe tried to charge your card ending in {{card.last4}} for {{invoice.amount}}, but the bank declined it.5добавленоWe tried to charge your card ending in {{card.last4}} for {{invoice.amount}}, but your bank declined it.667удаленоPlease update your payment details within 7 days to keep your subscription active.7добавленоPlease update your payment details within 14 days to keep your subscription active. We will try the card again on {{retry.date}}.8899Update payment details: {{billing.url}}1010Flag a changed line to discuss it before approval.Два окружения, один длинный файл
Файл на сто восемьдесят строк с тремя правками открывается на этих правках:
contextоставляет вокруг каждой по две строки и сворачивает остальное в пропуски,expand-stepоткрывает ещё двадцать с любого края, аexpandсообщает о каждом нажатии.max-heightделает область собственным скроллером. При сравнении с самим собой сводка говорит, что версии совпадают, а файл показан целиком: без правок сворачивать не вокруг чего.billing-service/config.yaml180 lines · production is the reference3 settings differ from staging1818 option_02: "database-2"1919 option_03: true20удалено option_04: 4020добавлено option_04: 102121 option_05: "database-5"2222 option_06: trueСкрыто 43 строки6666 option_05: "payments-5"6767 option_06: true68удалено option_07: "sepa,card"68добавлено option_07: "sepa,card,instant"6969 option_08: "payments-8"7070 option_09: trueСкрыто 105 строк176176 option_10: 50177177 option_11: "features-11"178удалено option_12: false178добавлено option_12: true179179 option_13: 65180180 option_14: "features-14"
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
language | string | undefined | undefined | Язык подсветки строк. Тот же словарь, что у блока. Не задан — `json`, если сравнивают значения, и `text`, если строки: объект сериализуем мы сами и точно знаем, что получилось. |
highlighter | GrCodeTokenizer | undefined | undefined | Подсветка для языков сверх встроенных. Не задана — берётся из `provide`/`inject` приложения (`GR_CODE_HIGHLIGHTER_KEY`). |
maxHeight | string | number | undefined | undefined | Высота, после которой включается вертикальный скролл; число — в пикселях. |
wrap | boolean | undefined | undefined | Переносить длинные строки вместо горизонтальной прокрутки. Не задан — из `GrConfigProvider`, иначе `false`. |
lineNumbers | boolean | undefined | undefined | Номера строк обеих сторон в жёлобе. Не задан — из `GrConfigProvider`, иначе `true`. |
ariaLabel | string | undefined | undefined | Имя области сравнения. Безымянную скринридер объявляет просто «регион». |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Ступень размера: кегль и отступы строк. По умолчанию `md`; берётся из `GrConfigProvider`. |
mode | GrDiffMode | undefined | undefined | Раскладка: `unified` — одна колонка со знаками `+`/`−`, `split` — «было» и «стало» рядом. Не задан — из `GrConfigProvider`, иначе `unified`. |
context | number | undefined | undefined | Неизменных строк вокруг изменения. `0` — только изменения, `Infinity` — всё. |
expandStep | number | undefined | undefined | Сколько строк открывает одно нажатие на пропуск. |
budget | number | undefined | GR_DIFF_DEFAULT_BUDGET | Предел работы алгоритма. За ним разбор огрубляется, а не вешает вкладку. |
before | unknown | — | Левая сторона. Не строка — сериализуется устойчивым порядком ключей. |
after | unknown | — | Правая сторона. |
hunks | GrDiffHunk[] | undefined | — | Готовый дифф. Сильнее `before`/`after`: считать заново нечего. |
Слоты
| Slot | Type | Описание |
|---|---|---|
summary | { added: number; removed: number; degraded: boolean; } | — |
empty | {} | — |
row-actions | { line: GrDiffLine; } | — |
События
| Event | Сигнатура | Описание |
|---|---|---|
budgetExceeded | (event: "budgetExceeded"): void | — |
expand | (event: "expand", gapId: string, edge: GrDiffGapEdge): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
scrollToRow | (index: number, align?: GrVirtualAlign | undefined) => void | Прокрутить к ряду по индексу — для «следующее изменение» у потребителя. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrDiffMode, GrDiffHunk, GrDiffProps } from '@feugene/granularity-code/components/GrDiff'Файлdist/types/components/GrDiff/config.d.ts
Зависимостей нет — и это заявленное свойство пакета: чтение диффа частотнее
правки конфига, и платить за редактор оно не должно. Проверяет это гейт
изоляции: разметка GrCodeEditor в entry диффа не пройдёт.
Класс-мапу ролей подсветки дифф берёт из общего с блоком модуля, но самого
блока не рендерит — ребра нет, есть общий .ts, и его классы granum
извлекает по графу бандла.
const grDiffConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDiff">;Файлdist/types/components/GrDiff/GrDiff.vue.d.ts
type GrDiffMode = 'unified' | 'split';Готовый дифф с бэкенда: участок одного вида.
interface GrDiffHunk {
op: 'equal' | 'add' | 'remove';
lines: string[];
}Сравнение двух текстов: что изменилось в записи, между ревизиями, между конфигами двух окружений. Не редактирует и не разрешает конфликты: «принять блок» — задача резолвера, другого компонента.
interface GrDiffProps {
/** Левая сторона. Не строка — сериализуется устойчивым порядком ключей. */
before?: unknown;
/** Правая сторона. */
after?: unknown;
/** Готовый дифф. Сильнее `before`/`after`: считать заново нечего. */
hunks?: GrDiffHunk[];
/**
* Раскладка: `unified` — одна колонка со знаками `+`/`−`, `split` — «было» и «стало» рядом.
* Не задан — из `GrConfigProvider`, иначе `unified`.
*/
mode?: GrDiffMode;
/**
* Язык подсветки строк. Тот же словарь, что у блока.
*
* Не задан — `json`, если сравнивают значения, и `text`, если строки: объект
* сериализуем мы сами и точно знаем, что получилось.
*/
language?: string;
/**
* Подсветка для языков сверх встроенных. Не задана — берётся из
* `provide`/`inject` приложения (`GR_CODE_HIGHLIGHTER_KEY`).
*/
highlighter?: GrCodeTokenizer;
/** Неизменных строк вокруг изменения. `0` — только изменения, `Infinity` — всё. */
context?: number;
/** Сколько строк открывает одно нажатие на пропуск. */
expandStep?: number;
/** Предел работы алгоритма. За ним разбор огрубляется, а не вешает вкладку. */
budget?: number;
/** Номера строк обеих сторон в жёлобе. Не задан — из `GrConfigProvider`, иначе `true`. */
lineNumbers?: boolean;
/**
* Переносить длинные строки вместо горизонтальной прокрутки. Не задан — из `GrConfigProvider`,
* иначе `false`.
*/
wrap?: boolean;
/** Высота, после которой включается вертикальный скролл; число — в пикселях. */
maxHeight?: string | number;
/** Ступень размера: кегль и отступы строк. По умолчанию `md`; берётся из `GrConfigProvider`. */
size?: GrComponentSize;
/** Имя области сравнения. Безымянную скринридер объявляет просто «регион». */
ariaLabel?: string;
}interface GrDiffEmits {
/** Пропуск раскрыт с края: потребитель волен запомнить это между показами. */
(e: 'expand', gapId: string, edge: GrDiffGapEdge): void;
/** Бюджет исчерпан, разбор огрублён. */
(e: 'budgetExceeded'): void;
}Файлdist/types/components/GrDiff/defaults.d.ts
Пропы GrDiff, настраиваемые глобально через
<GrConfigProvider :component-defaults="{ GrDiff: { … } }">.
Только оформление: стороны сравнения, язык и подсветка принадлежат
экземпляру. context и expandStep здесь не случайно — сколько строк вокруг
правки показывать и по сколько открывать пропуск, решают один раз на
приложение, а не у каждого диффа.
interface GrDiffConfigurableProps {
size: GrComponentSize;
mode: 'unified' | 'split';
wrap: boolean;
lineNumbers: boolean;
context: number;
expandStep: number;
}Файлdist/types/components/GrDiff/safelist.d.ts
const grDiffSafelist: string[];Доступность
Паттерн APG
область + кнопки пропусков
| Клавиша | Действие |
|---|---|
| — | своих нет. Область сравнения — скроллер и потому в таб-порядке; кнопка раскрытия пропуска — обычная кнопка |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Дифф считается своим алгоритмом, а не @codemirror/merge
Готовый merge-вид дал бы меньше кода у нас и обязательный CodeMirror у того, кто пришёл просто посмотреть, что изменилось. Из трёх сценариев пакета чтение диффа — самый частый: журнал открывают все, конфиг правит один администратор из ста. Платить за редактор чтению незачем.
Второй довод — оформление: у чужого merge-вида своя палитра и свои классы, наши токены заходят туда переопределением чужих селекторов и держатся до следующего минора библиотеки.
Бюджет: почему у сравнения есть предел
Сложность алгоритма Майерса зависит от дистанции редактирования, а не от длины входа. Два файла по десять тысяч строк, отличающиеся целиком, — это замерший таб, и увидит его пользователь, а не разработчик.
За пределом (budget) разбор уходит на огрублённый проход: общий префикс и
суффикс остаются, середина считается заменённой целиком. Дифф остаётся
верным — просто менее подробным, — и сообщает об этом эмитом
budgetExceeded и строкой в сводке. Это лучше зависшей вкладки и честнее
молчания.
Пословная подсветка
Внутри изменённой строки отмечается изменённое слово. Без этого правка одного слова читается как «строка целиком другая», и дифф перестаёт отвечать на свой вопрос.
Строка без пробелов — длинный base64, минифицированный JSON — от пословного разбора освобождается: он вырождается в посимвольный, подсвечивает каждый второй знак и стоит дорого. Такая строка честнее показывается изменённой целиком.
Пара для разбора берётся из блока правки целиком, а не из соседних рядов.
diffLines выдаёт блок сначала удалениями, потом добавлениями
(-a -b -c +A +B +C), и счёт по соседям свёл бы вместе -c и +A — строки,
друг к другу не относящиеся. k-е удаление встаёт против k-го добавления; блок
разной длины добивается пустой стороной.
Схлопывание
Дифф конфига на тысячу строк с одной правкой обязан открываться показом этой
правки. Вокруг каждого изменения остаётся context строк, остальное
сворачивается в раскрываемый пропуск.
context: 0 оставляет только изменения, Infinity не сворачивает ничего.
Пропуск раскрывается шагами с любого края, как в обзоре кода: нужный кусок
ищут рядом с правкой, а не разворачивают весь файл. Полоса пропуска несёт две
кнопки — «↓ N» открывает N строк в начале пропуска, «↑ N» в конце, — и счётчик
между ними. Размер шага задаёт expandStep (по умолчанию 10) и, как и
context, настраивается на приложение через GrConfigProvider.
Остаток меньше шага открывается целиком, и полоса превращается в одну кнопку: кнопка, которая больше ничего не откроет, — тупик, в который упираются ровно один раз и больше ей не верят.
Раскрытие сверху удерживает пропуск на месте: строки встают над ним, и без поправки на их высоту содержимое уезжает вниз ровно на столько же — пользователь нажал «показать ещё» и потерял из виду ту самую правку, ради которой смотрел. Прирост берётся замером, а не расчётом «строк × высоту»: высота строки зависит от кегля и переноса.
Раскрытие одного пропуска не трогает соседние: их идентификатор — позиция первой строки участка, а не первой скрытой и не порядковый номер. Считай его по скрытым — он менялся бы на каждом шаге, и состояние теряло бы свой же пропуск: второе нажатие открывало бы его заново с нуля.
Доступность
Сводка объявлена живым регионом: без неё диктор читает поток строк, не понимая, что перед ним сравнение.
Цвет не единственный носитель смысла — иначе это WCAG 1.4.1. Добавленное и
удалённое различаются знаком в жёлобе (+ / −): дифф читают в том числе на
монохромной печати. Знак стоит в обоих режимах: в split он в каждой
колонке своей, а не только в unified.
Область сравнения — скроллер и потому достижима с клавиатуры.
Виртуализация требует распорок
Длинный дифф режется окном отрисовки, и useVirtualList отдаёт только высоты
распорок переменными — сами псевдоэлементы объявляет компонент, в своём
<style>. Без этого правила переменные некому прочитать: контейнер остаётся
высотой в одно окно, прокрутки нет вовсе, и из тысячи строк достижим первый
десяток. Разметка при этом валидна, а отказ виден только глазами — поэтому
правило держит гейт virtualSpacer.test.ts.
Слот row-actions
Ряд отдаётся слотом целиком — под кнопку «скопировать строку», ссылку на
обсуждение, метку рецензента. Слот получает line и работает в обоих режимах;
в split приходит строка правой стороны, а для одинокого удаления — левой.
Границы
Не редактирует. Не разрешает конфликты. Не разбирает unified diff от git: hunks
принимаются своей типизованной структурой, потому что у парсера unified diff
есть края (\ No newline at end of file, арифметика заголовка, бинарные файлы),
а парсер, ошибающийся на краю, хуже отсутствующего — он не падает, а показывает
неверное сравнение.
Пропы этого примера