GrDiff

Пакет: @feugene/granularity-codeспутникГруппа: Прочее

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

Когда брать

  • Журнал аудита: две версии записи рядом, видно ровно то, что правили.
  • Ревизии документа: «чем эта отличается от предыдущей».
  • Конфиги двух окружений: прод против стейджа, построчно.
  • Ответ бэкенда с готовым диффом: приходит в 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

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

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

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

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
область + кнопки пропусков
Клавиши
своих нет. Область сравнения — скроллер и потому в таб-порядке; кнопка раскрытия пропуска — обычная кнопка

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты