GrDiff

  • code
  • Код
  • API · 14 props
  • ⌨ клавиши

Берут, когда Журнал аудита.

import { GrDiff } from '@feugene/granularity-code/components/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, удалено: 3
    1удаленоSubject: Your payment for {{invoice.number}} did not go through
    1добавленоSubject: Action needed: payment for {{invoice.number}} failed
    22
    33Hi {{customer.name}},
    44
    5удалено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.
    66
    7удалено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}}.
    88
    99Update payment details: {{billing.url}}
    1010
    Flag a changed line to discuss it before approval.
  • Два окружения, один длинный файл

    Файл на сто восемьдесят строк с тремя правками открывается на этих правках: context оставляет вокруг каждой по две строки и сворачивает остальное в пропуски, expand-step открывает ещё двадцать с любого края, а expand сообщает о каждом нажатии. max-height делает область собственным скроллером. При сравнении с самим собой сводка говорит, что версии совпадают, а файл показан целиком: без правок сворачивать не вокруг чего.

    billing-service/config.yaml180 lines · production is the reference
    3 settings differ from staging
    1818 option_02: "database-2"
    1919 option_03: true
    20удалено option_04: 40
    20добавлено option_04: 10
    2121 option_05: "database-5"
    2222 option_06: true
    Скрыто 43 строки
    6666 option_05: "payments-5"
    6767 option_06: true
    68удалено option_07: "sepa,card"
    68добавлено option_07: "sepa,card,instant"
    6969 option_08: "payments-8"
    7070 option_09: true
    Скрыто 105 строк
    176176 option_10: 50
    177177 option_11: "features-11"
    178удалено option_12: false
    178добавлено option_12: true
    179179 option_13: 65
    180180 option_14: "features-14"

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
languagestring | undefinedundefinedЯзык подсветки строк. Тот же словарь, что у блока. Не задан — `json`, если сравнивают значения, и `text`, если строки: объект сериализуем мы сами и точно знаем, что получилось.
highlighterGrCodeTokenizer | undefinedundefinedПодсветка для языков сверх встроенных. Не задана — берётся из `provide`/`inject` приложения (`GR_CODE_HIGHLIGHTER_KEY`).
maxHeightstring | number | undefinedundefinedВысота, после которой включается вертикальный скролл; число — в пикселях.
wrapboolean | undefinedundefinedПереносить длинные строки вместо горизонтальной прокрутки. Не задан — из `GrConfigProvider`, иначе `false`.
lineNumbersboolean | undefinedundefinedНомера строк обеих сторон в жёлобе. Не задан — из `GrConfigProvider`, иначе `true`.
ariaLabelstring | undefinedundefinedИмя области сравнения. Безымянную скринридер объявляет просто «регион».
size"xs" | "sm" | "md" | "lg" | undefinedundefinedСтупень размера: кегль и отступы строк. По умолчанию `md`; берётся из `GrConfigProvider`.
modeGrDiffMode | undefinedundefinedРаскладка: `unified` — одна колонка со знаками `+`/`−`, `split` — «было» и «стало» рядом. Не задан — из `GrConfigProvider`, иначе `unified`.
contextnumber | undefinedundefinedНеизменных строк вокруг изменения. `0` — только изменения, `Infinity` — всё.
expandStepnumber | undefinedundefinedСколько строк открывает одно нажатие на пропуск.
budgetnumber | undefinedGR_DIFF_DEFAULT_BUDGETПредел работы алгоритма. За ним разбор огрубляется, а не вешает вкладку.
beforeunknown—Левая сторона. Не строка — сериализуется устойчивым порядком ключей.
afterunknown—Правая сторона.
hunksGrDiffHunk[] | undefined—Готовый дифф. Сильнее `before`/`after`: считать заново нечего.

Слоты

SlotTypeОписание
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

grDiffConfigconst

Зависимостей нет — и это заявленное свойство пакета: чтение диффа частотнее правки конфига, и платить за редактор оно не должно. Проверяет это гейт изоляции: разметка GrCodeEditor в entry диффа не пройдёт. Класс-мапу ролей подсветки дифф берёт из общего с блоком модуля, но самого блока не рендерит — ребра нет, есть общий .ts, и его классы granum извлекает по графу бандла.

const grDiffConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDiff">;

Файлdist/types/components/GrDiff/GrDiff.vue.d.ts

GrDiffModetype
type GrDiffMode = 'unified' | 'split';
GrDiffHunkinterface

Готовый дифф с бэкенда: участок одного вида.

interface GrDiffHunk {
    op: 'equal' | 'add' | 'remove';
    lines: string[];
}
GrDiffPropsinterface

Сравнение двух текстов: что изменилось в записи, между ревизиями, между конфигами двух окружений. Не редактирует и не разрешает конфликты: «принять блок» — задача резолвера, другого компонента.

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;
}
GrDiffEmitsinterface
interface GrDiffEmits {
    /** Пропуск раскрыт с края: потребитель волен запомнить это между показами. */
    (e: 'expand', gapId: string, edge: GrDiffGapEdge): void;
    /** Бюджет исчерпан, разбор огрублён. */
    (e: 'budgetExceeded'): void;
}

Файлdist/types/components/GrDiff/defaults.d.ts

GrDiffConfigurablePropsinterface

Пропы 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

grDiffSafelistconst
const grDiffSafelist: string[];

Доступность

Паттерн APG

область + кнопки пропусков

КлавишаДействие
—своих нет. Область сравнения — скроллер и потому в таб-порядке; кнопка раскрытия пропуска — обычная кнопка

Использование из прозы библиотеки

Дифф считается своим алгоритмом, а не @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, арифметика заголовка, бинарные файлы), а парсер, ошибающийся на краю, хуже отсутствующего — он не падает, а показывает неверное сравнение.