GrDiff

Package: @feugene/granularity-codecompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • An audit log: two versions of a record side by side, showing exactly what was edited.
  • The revisions of a document: “how this one differs from the previous”.
  • The configs of two environments: production against staging, line by line.
  • An answer from the backend with a ready diff: it arrives in hunks, and there is nothing to compute again.

The value does not have to be a string: an object is serialised with a stable order of keys. Without that two objects with the same content and a different order would give invented differences — an edit where there was none.

When to take something else

TaskComponent
Show code without a comparisonGrCodeBlock
Let the code be editedGrCodeEditor
Walk someone else’s unknown with branches expandedGrJsonViewer (the core)
Accept or reject a block of changesnothing: that is a conflict resolver, a different component

The diff is computed with an algorithm of its own rather than with `@codemirror/merge`

A ready merge view would give less code on our side and a mandatory CodeMirror for whoever came simply to look at what changed. Of the three scenarios of the package, reading a diff is the most frequent: the log is opened by everyone, and the config is edited by one administrator in a hundred. There is no point making reading pay for the editor.

The second argument is the styling: someone else’s merge view has a palette and classes of its own, and our tokens get in there by overriding foreign selectors and hold until the next minor of the library.

The budget: why a comparison has a limit

The complexity of Myers’s algorithm depends on the edit distance rather than on the length of the input. Two files of ten thousand lines each, differing entirely, are a frozen tab, and it is the user who will see it rather than the developer.

Beyond the limit (budget) the parsing goes into a coarse pass: the common prefix and suffix remain, and the middle is counted as replaced entirely. The diff stays correct — simply less detailed — and reports that with a budgetExceeded emit and a line in the summary. That is better than a frozen tab and more honest than silence.

Word-level highlighting

Inside a changed line the changed word is marked. Without that an edit of one word reads as “the whole line is different”, and the diff stops answering its own question.

A line with no spaces — a long base64, a minified JSON — is exempted from the word-level parsing: it degenerates into a character-level one, highlights every other sign and costs a lot. Such a line is shown more honestly as changed in full.

The pair for the parsing is taken from the block of the edit as a whole rather than from the neighbouring rows. diffLines outputs a block with the deletions first and the additions after (-a -b -c +A +B +C), and counting by neighbours would bring together -c and +A — lines that have nothing to do with each other. The k-th deletion stands against the k-th addition; a block of unequal length is padded with an empty side.

Collapsing

A diff of a config of a thousand lines with one edit is obliged to open showing that edit. context lines are left around every change, and the rest is folded into an expandable gap.

context: 0 leaves the changes alone, and Infinity folds nothing.

A gap is expanded in steps from either edge, as in a code review: the piece you need is looked for next to the edit rather than by unfolding the whole file. The strip of the gap carries two buttons — ”↓ N” opens N lines at the start of the gap, ”↑ N” at the end — and a counter between them. The size of the step is set by expandStep (10 by default) and, like context, is configured for the application through GrConfigProvider.

A remainder smaller than the step is opened in full, and the strip turns into a single button: a button that will open nothing more is a dead end people run into exactly once and then stop believing it.

Expanding from the top holds the gap in place: the lines stand above it, and without a correction for their height the content moves down by exactly as much — the user pressed “show more” and lost sight of the very edit they were looking at. The increment is taken by measurement rather than by computing “lines × height”: the height of a line depends on the type size and on wrapping.

Expanding one gap does not touch its neighbours: their identifier is the position of the first line of the section rather than of the first hidden one and not an ordinal number. Count it by the hidden ones and it would change at every step, and the state would lose its own gap: a second press would open it again from scratch.

Accessibility

The summary is declared a live region: without it a screen reader reads a stream of lines without understanding that a comparison is in front of it.

Colour is not the only carrier of meaning — otherwise that is WCAG 1.4.1. What was added and what was removed are told apart by a sign in the gutter (+ / ): a diff is read on monochrome printouts as well. The sign stands in both modes: in split it is in every column of its own rather than only in unified.

The comparison area is a scroller and is therefore reachable from the keyboard.

Virtualisation requires spacers

A long diff is cut by a rendering window, and useVirtualList gives away only the heights of the spacers as variables — the pseudo-elements themselves are declared by the component, in its own <style>. Without that rule there is nobody to read the variables: the container stays one window tall, there is no scrolling at all, and out of a thousand lines the first dozen are reachable. The markup is valid in the process, and the failure is visible only to the eye — which is why the rule is held by the virtualSpacer.test.ts gate.

The `row-actions` slot

The row is given away by the slot as a whole — for a “copy the line” button, a link to a discussion, the mark of a reviewer. The slot receives line and works in both modes; in split the line of the right side arrives, and for a lone deletion the line of the left one.

Limits

It does not edit. It does not resolve conflicts. It does not parse a unified diff from git: hunks are accepted as a typed structure of their own, because a parser of a unified diff has edges (\ No newline at end of file, the arithmetic of the header, binary files), and a parser that is wrong at an edge is worse than a missing one — it does not fail, it shows a wrong comparison.

Install

npm i @feugene/granularity-code

Import

import { GrDiff } from '@feugene/granularity-code/components/GrDiff'

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 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>

Accessibility

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

Full keyboard contract of the package

Component documentationAll components