GrDiff
Берут, когда Журнал аудита.
Когда брать
- Журнал аудита: две версии записи рядом, видно ровно то, что правили.
- Ревизии документа: «чем эта отличается от предыдущей».
- Конфиги двух окружений: прод против стейджа, построчно.
- Ответ бэкенда с готовым диффом: приходит в
hunks, считать заново нечего.
Значение не обязано быть строкой: объект сериализуется устойчивым порядком ключей. Без этого два объекта с одинаковым содержимым и разным порядком дали бы выдуманные различия — правку там, где её не было.
Когда взять другое
| Задача | Компонент |
|---|---|
| Показать код без сравнения | GrCodeBlock |
| Дать код править | GrCodeEditor |
Обойти чужой unknown с раскрытием веток | GrJsonViewer (ядро) |
| Принять или отклонить блок изменений | ничего: это резолвер конфликтов, другой компонент |
Дифф считается своим алгоритмом, а не `@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, арифметика заголовка, бинарные файлы),
а парсер, ошибающийся на краю, хуже отсутствующего — он не падает, а показывает
неверное сравнение.
Установка
npm i @feugene/granularity-codeИмпорт
import { GrDiff } from '@feugene/granularity-code/components/GrDiff'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 4
Hunks
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrSwitch } from '@feugene/granularity'
import type { GrDiffHunk } from '@feugene/granularity-code'
/**
* Дифф, посчитанный на сервере: git отдал участки, считать заново нечего.
*
* `hunks` сильнее `before`/`after` — компонент только нумерует строки и рисует.
*/
const HUNKS: GrDiffHunk[] = [
{ op: 'equal', lines: ['def deploy(env):', ' check_health(env)'] },
{ op: 'remove', lines: [' rollout(env, strategy="recreate")'] },
{ op: 'add', lines: [' rollout(env, strategy="rolling")', ' wait_for_ready(env, timeout=120)'] },
{ op: 'equal', lines: [' notify(env)', ' return True'] },
]
const empty = ref(false)
const copied = ref<string | null>(null)
const hunks = computed(() => empty.value ? [] : HUNKS)
function copyLine(text: string): void {
copied.value = text.trim()
}
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="empty" size="sm">
Сервер вернул пустой ответ
</GrSwitch>
<GrDiff :hunks="hunks" language="text">
<!-- Своя сводка вместо встроенной: слот получает готовые числа. -->
<template #summary="{ added, removed }">
<span class="showcase-demo-text text-sm">
Ревизия <b>a81f3c</b> · <b>+{{ added }}</b> / <b>−{{ removed }}</b>
</span>
</template>
<!-- Пустое сравнение: своё состояние вместо встроенного текста. -->
<template #empty>
<div class="showcase-demo-text px-3 py-4 text-sm">
Ревизия ещё не собрана — сравнивать нечего
</div>
</template>
<!--
Действие на строке: в обзоре кода тут живут «обсудить» и «скопировать».
Слот получает саму строку, поэтому решать, кому действие нужно, может
потребитель — здесь оно только у изменённых.
-->
<template #row-actions="{ line }">
<GrButton v-if="line.op !== 'equal'" size="xs" variant="ghost" @click="copyLine(line.text)">
копировать
</GrButton>
</template>
</GrDiff>
<p class="showcase-demo-text text-sm">
Последняя скопированная строка: <b>{{ copied ?? '—' }}</b>
</p>
</div>
</template>Modes
<script setup lang="ts">
import { ref } from 'vue'
import { GrSegmented, GrSwitch } from '@feugene/granularity'
/** Две ревизии конфига окружения — типичный вход журнала аудита. */
const BEFORE = `service: billing
replicas: 2
resources:
cpu: 500m
memory: 512Mi
env:
LOG_LEVEL: info
TIMEOUT_MS: 3000
RETRIES: 3
healthcheck:
path: /health
interval: 10s`
const AFTER = `service: billing
replicas: 4
resources:
cpu: 1000m
memory: 512Mi
env:
LOG_LEVEL: debug
TIMEOUT_MS: 3000
RETRIES: 5
healthcheck:
path: /health
interval: 10s`
const mode = ref<'unified' | 'split'>('unified')
const collapse = ref(true)
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<GrSegmented
v-model="mode"
size="sm"
:options="[
{ value: 'unified', label: 'Одной колонкой' },
{ value: 'split', label: 'Двумя' },
]"
/>
<GrSwitch v-model="collapse" size="sm">
Сворачивать неизменное
</GrSwitch>
</div>
<GrDiff :before="BEFORE" :after="AFTER" :mode="mode" :context="collapse ? 1 : Infinity" />
</div>
</template>Objects
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrSwitch } from '@feugene/granularity'
/**
* Ревизии записи приходят объектами, и порядок ключей у них разный: одна
* пришла из API, другая собрана в форме.
*/
const PREVIOUS = { id: 41, title: 'Договор поставки', status: 'draft', amount: 120000, signed: false }
const CURRENT = { status: 'signed', title: 'Договор поставки № 41', id: 41, signed: true, amount: 120000 }
/**
* Та же запись, ключи переставлены.
*
* Именно этим и проверяется устойчивая сериализация: сравнение обязано сказать
* «изменений нет». Копия с тем же порядком ключей не доказывала бы ничего —
* с ней совпал бы и наивный `JSON.stringify`.
*/
const REORDERED = { signed: false, amount: 120000, status: 'draft', title: 'Договор поставки', id: 41 }
const compareRevisions = ref(true)
const rightSide = computed(() => compareRevisions.value
? 'ревизия из API'
: 'та же запись, ключи переставлены')
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="compareRevisions" size="sm">
Сравнивать с новой ревизией
</GrSwitch>
<GrDiff :before="PREVIOUS" :after="compareRevisions ? CURRENT : REORDERED" />
<p class="showcase-demo-text text-sm">
Справа: <b>{{ rightSide }}</b>
</p>
</div>
</template>Scale
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { GrSegmented, GrSwitch } from '@feugene/granularity'
import { diffLines, GR_DIFF_DEFAULT_BUDGET } from '@feugene/granularity-code/diff'
const lineCount = ref(1000)
const expandStep = ref(10)
const diverged = ref(false)
const degraded = ref(false)
const before = computed(() =>
Array.from({ length: lineCount.value }, (_, index) => ` "field_${index}": "value ${index}",`).join('\n'))
/**
* Две ревизии одного файла — и сравнение с чужим файлом.
*
* Разница не в размере, а в **дистанции редактирования**: одна правка на тысячу
* строк считается мгновенно при любом объёме, а сотни расхождений упираются в
* предел. Показать отказ на файле с одной правкой нельзя — бюджету нечего
* исчерпывать.
*/
const after = computed(() => diverged.value
? Array.from({ length: lineCount.value }, (_, index) =>
` "field_${index}": "${index % 3 === 0 ? `rewritten ${index}` : `value ${index}`}",`).join('\n')
: before.value.replace('"value 500"', '"value 500 changed"'))
/**
* Бюджет — предел работы алгоритма, а не украшение: два больших разных файла без
* него это замершая вкладка. За пределом разбор огрубляется и говорит об этом.
*/
const budget = computed(() => diverged.value ? 20 : GR_DIFF_DEFAULT_BUDGET)
// Сообщение об огрублении живёт до следующего входа, а не до конца сессии.
watch([lineCount, diverged], () => {
degraded.value = false
})
/** Тот же счёт, что делает компонент: сколько строк вообще в сравнении. */
const stats = computed(() => {
const result = diffLines(before.value, after.value, { budget: budget.value })
return { total: result.lines.length, added: result.added, removed: result.removed }
})
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<GrSegmented
v-model="lineCount"
size="sm"
:options="[
{ value: 200, label: '200 строк' },
{ value: 1000, label: '1000' },
{ value: 5000, label: '5000' },
]"
/>
<GrSegmented
v-model="expandStep"
size="sm"
:options="[
{ value: 5, label: 'по 5' },
{ value: 10, label: 'по 10' },
{ value: 50, label: 'по 50' },
]"
/>
<GrSwitch v-model="diverged" size="sm">
Чужой файл, низкий бюджет
</GrSwitch>
</div>
<p class="showcase-demo-text text-sm">
Строк в сравнении: <b>{{ stats.total }}</b>, изменено: {{ stats.added }} добавлено,
{{ stats.removed }} удалено. В DOM при этом — десятки строк: неизменное свёрнуто,
а остальное режется окном отрисовки.
<template v-if="degraded">
<b>Бюджет исчерпан</b> — разбор огрублён: это отказ, который видит пользователь, а не
замершая вкладка.
</template>
</p>
<GrDiff
:before="before"
:after="after"
:context="2"
:expand-step="expandStep"
:budget="budget"
language="json"
max-height="20rem"
@budget-exceeded="degraded = true"
/>
</div>
</template>Доступность
- Паттерн APG
область + кнопки пропусков- Клавиши
- своих нет. Область сравнения — скроллер и потому в таб-порядке; кнопка раскрытия пропуска — обычная кнопка