- Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container. - Added lazy loading functionality to improve performance by loading file content as it approaches the viewport. - Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews. - Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation. - Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options. - Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience.
55 KiB
Phase 4: Portion Collapse
Обзор
Проблема: Стандартный collapseUnchanged из @codemirror/merge при клике на collapsed region разворачивает ВСЮ зону целиком. Для файлов с 500+ неизменённых строк между изменениями это создаёт резкий скачок контента и потерю контекста. GitHub решает это кнопками "Expand 20 lines" / "Expand all", позволяя раскрывать порциями.
Решение: Кастомный CodeMirror StateField (portionCollapseExtension) который создаёт Decoration.replace с виджетами, содержащими кнопки "Expand N" и "Expand All". При клике на "Expand N" виджет разворачивает только указанное количество строк, оставляя остаток свёрнутым.
Зависимости: Независим от Phase 1-3 (continuous scroll). Может использоваться как в single-file mode, так и в continuous mode.
Почему кастомный StateField
Ограничения CM's collapseUnchanged
@codemirror/merge реализует collapseUnchanged через приватный StateField CollapsedRanges + Decoration.replace с внутренним CollapseWidget. При клике на collapsed widget используется StateEffect uncollapseUnchanged (экспортируется из @codemirror/merge), который ПОЛНОСТЬЮ удаляет decoration для зоны через deco.update({ filter: from => from != e.value }).
// Экспорт из @codemirror/merge
declare const uncollapseUnchanged: StateEffectType<number>;
Ключевая деталь реализации CM: CollapsedRanges использует паттерн StateField.define + StateField.init():
create()возвращаетDecoration.none(пустые decorations)collapseUnchanged()возвращаетCollapsedRanges.init(state => buildCollapsedRanges(state, margin, minSize))— init переопределяет create при инициализации stateupdate()делает ТОЛЬКОdeco.map(tr.changes)+ filter поuncollapseUnchangedeffectupdate()НЕ делает rebuild приdocChangedилиupdateOriginalDoc— CM пересоздаёт collapse decorations через reconfigure compartment при изменении chunks
Проблемы:
- Нет partial expand —
uncollapseUnchangedпринимает толькоpos: numberи разворачивает всю зону - Нет public API для получения списка collapsed зон или модификации отдельных зон
- WidgetType внутренний (CollapseWidget) — нет возможности заменить DOM widget без форка
- CollapsedRanges StateField — приватный, недоступен для расширения
Почему не обёртка
Теоретически можно было бы:
- Перехватить
uncollapseUnchangedeffect в транзакции - Вместо полного uncollapse — создать два новых collapsed regionа
- Но
uncollapseUnchangedпривязан к внутреннемуCollapsedRangesStateField, который фильтрует decorations поfromposition
Это хрупко и сломается при обновлении @codemirror/merge. Надёжнее написать свой StateField.
Новый файл: portionCollapse.ts
Путь: src/renderer/components/team/review/portionCollapse.ts
Exports
import {
Decoration,
type DecorationSet,
EditorView,
WidgetType,
} from '@codemirror/view';
import {
type ChangeDesc,
type EditorState,
type Extension,
RangeSetBuilder,
StateEffect,
type StateEffectType,
StateField,
type Transaction,
} from '@codemirror/state';
import { getChunks, type updateOriginalDoc } from './CodeMirrorDiffUtils';
// updateOriginalDoc используется только для .is() проверки в update(),
// поэтому импортируем его напрямую:
import { updateOriginalDoc } from '@codemirror/merge';
// ─── Configuration ───
interface PortionCollapseConfig {
/**
* Количество строк контекста, оставляемых видимыми до/после изменения.
* Default: 3
* Соответствует поведению CM's collapseUnchanged.margin.
*/
margin?: number;
/**
* Минимальное количество строк в unchanged зоне для создания collapse.
* Зоны короче этого значения остаются видимыми целиком.
* Default: 4
* Соответствует CM's collapseUnchanged.minSize.
*/
minSize?: number;
/**
* Количество строк, раскрываемых за одно нажатие "Expand N".
* Default: 100
* При меньшем остатке кнопка показывает "Expand <остаток>".
*/
portionSize?: number;
}
// ─── State Effects ───
/**
* Раскрыть portionSize строк из collapsed зоны.
* pos — позиция начала текущей collapsed decoration.
* count — количество строк для раскрытия (обычно = portionSize).
*
* StateEffect.define с map() для корректного ремаппинга при изменениях документа.
* map callback: (value, mapping: ChangeDesc) => Value | undefined.
* Возврат undefined удаляет effect (не наш случай — always remap).
*/
export const expandPortion: StateEffectType<{
pos: number;
count: number;
}> = StateEffect.define<{ pos: number; count: number }>({
map: (value, mapping: ChangeDesc) => ({
pos: mapping.mapPos(value.pos),
count: value.count,
}),
});
/**
* Полностью раскрыть collapsed зону по позиции.
* pos — позиция начала collapsed decoration.
*/
export const expandAllAtPos: StateEffectType<number> = StateEffect.define<number>({
map: (pos, mapping: ChangeDesc) => mapping.mapPos(pos),
});
// ─── Public API ───
/**
* Создаёт Extension для порционного collapse неизменённых зон.
*
* ВАЖНО: Эта extension НЕ совместима с collapseUnchanged из unifiedMergeView.
* Если portionCollapse включён — collapseUnchanged в mergeConfig НЕ должен быть задан.
*
* @param config — опциональная конфигурация
* @returns Extension для добавления в EditorView
*/
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension;
Внутренняя структура
PortionCollapseWidget
/**
* Widget для отображения collapsed зоны с кнопками "Expand N" / "Expand All".
*
* Визуально повторяет стиль .cm-collapsedLines из CM's collapseUnchanged,
* но с двумя кнопками вместо одной кликабельной полосы.
*
* Сравнение с CM's CollapseWidget:
* - CM: `ignoreEvent(e) { return e instanceof MouseEvent; }` (игнорирует ВСЕ MouseEvent'ы)
* - Мы: `ignoreEvent(e) { return e.type === 'mousedown'; }` (игнорируем только mousedown)
* - CM: `estimatedHeight` = 27 (фиксированная высота виджета)
* - Мы: `estimatedHeight` = 28 (наш виджет чуть выше из-за кнопок)
*/
class PortionCollapseWidget extends WidgetType {
/**
* @param lineCount — количество скрытых строк в этой зоне
* @param pos — позиция начала decoration в документе (для dispatch effects)
* @param portionSize — количество строк для "Expand N" кнопки
*/
constructor(
readonly lineCount: number,
readonly pos: number,
readonly portionSize: number
) {
super();
}
/**
* Создаёт DOM для collapsed зоны.
*
* Структура DOM:
* ```html
* <div class="cm-portion-collapse">
* <span class="cm-portion-collapse-text">
* ··· 247 unchanged lines ···
* </span>
* <div class="cm-portion-collapse-actions">
* <button class="cm-portion-expand-btn">
* Expand 100
* </button>
* <button class="cm-portion-expand-all-btn">
* Expand All
* </button>
* </div>
* </div>
* ```
*
* Кнопка "Expand N":
* - Если lineCount <= portionSize: скрывается (остаётся только "Expand All")
* - Если lineCount > portionSize: показывает "Expand {portionSize}"
* - При клике: dispatch expandPortion.of({ pos: this.pos, count: this.portionSize })
*
* Кнопка "Expand All":
* - Всегда видна
* - При клике: dispatch expandAllAtPos.of(this.pos)
*
* ВАЖНО: Обе кнопки используют onmousedown (не onclick) с preventDefault()
* чтобы предотвратить потерю фокуса CM editor.
* Паттерн аналогичен CM's CollapseWidget (addEventListener("click")),
* но mousedown + preventDefault надёжнее предотвращает перемещение selection.
*/
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-portion-collapse';
// Текст: "··· N unchanged lines ···"
const text = document.createElement('span');
text.className = 'cm-portion-collapse-text';
text.textContent = `\u00B7\u00B7\u00B7 ${this.lineCount} unchanged line${this.lineCount !== 1 ? 's' : ''} \u00B7\u00B7\u00B7`;
container.appendChild(text);
// Actions container
const actions = document.createElement('div');
actions.className = 'cm-portion-collapse-actions';
// "Expand N" button (только если lineCount > portionSize)
if (this.lineCount > this.portionSize) {
const expandBtn = document.createElement('button');
expandBtn.className = 'cm-portion-expand-btn';
expandBtn.textContent = `Expand ${this.portionSize}`;
expandBtn.title = `Show next ${this.portionSize} lines`;
expandBtn.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: expandPortion.of({
pos: this.pos,
count: this.portionSize,
}),
});
};
actions.appendChild(expandBtn);
}
// "Expand All" button (всегда)
const expandAllBtn = document.createElement('button');
expandAllBtn.className = 'cm-portion-expand-all-btn';
expandAllBtn.textContent = 'Expand All';
expandAllBtn.title = `Show all ${this.lineCount} unchanged lines`;
expandAllBtn.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: expandAllAtPos.of(this.pos),
});
};
actions.appendChild(expandAllBtn);
container.appendChild(actions);
return container;
}
/**
* Сравнение виджетов для оптимизации рендеринга.
* CM вызывает eq() при обновлении decorations — если true, DOM не пересоздаётся.
*
* ВАЖНО: pos НЕ нужно сравнивать в eq(). CM вызывает eq() только для decorations
* на ОДИНАКОВЫХ позициях. Если позиция decoration изменилась — это уже другой range,
* и CM не вызовет eq(). Сравниваем только визуально-значимые параметры.
*/
eq(other: PortionCollapseWidget): boolean {
return (
this.lineCount === other.lineCount &&
this.portionSize === other.portionSize
);
}
/**
* Оценка высоты widget для scrollbar.
*
* CM использует estimatedHeight для замещающих (replace) decorations
* чтобы скорректировать scrollbar. Возвращаем высоту самого widget (28px),
* а НЕ высоту скрытого контента. CM's CollapseWidget возвращает 27.
*
* Это корректно: scrollbar должен отражать ВИДИМУЮ высоту документа.
* Скрытые строки не занимают места — вместо них виден widget.
*/
get estimatedHeight(): number {
return 28;
}
/**
* Ignore events: позволяет кнопкам внутри widget обрабатывать клики.
* Без этого CM перехватит mousedown и поставит курсор.
*
* CM's CollapseWidget использует `e instanceof MouseEvent` (блокирует все mouse events).
* Мы используем проверку по type для большей точности.
*/
ignoreEvent(event: Event): boolean {
return event instanceof MouseEvent;
}
}
buildPortionRanges()
/**
* Вычисляет ranges для collapsed зон на основе текущих chunks.
*
* Алгоритм повторяет CM's buildCollapsedRanges() из @codemirror/merge,
* но с добавлением portionSize для PortionCollapseWidget.
*
* Оригинальный алгоритм CM (для справки):
* ```javascript
* function buildCollapsedRanges(state, margin, minLines) {
* let builder = new RangeSetBuilder();
* let isA = state.facet(mergeConfig).side == "a";
* let chunks = state.field(ChunkField);
* let prevLine = 1;
* for (let i = 0;; i++) {
* let chunk = i < chunks.length ? chunks[i] : null;
* let collapseFrom = i ? prevLine + margin : 1;
* let collapseTo = chunk
* ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin
* : state.doc.lines;
* let lines = collapseTo - collapseFrom + 1;
* if (lines >= minLines) {
* builder.add(
* state.doc.line(collapseFrom).from,
* state.doc.line(collapseTo).to,
* Decoration.replace({ widget: new CollapseWidget(lines), block: true })
* );
* }
* if (!chunk) break;
* prevLine = state.doc.lineAt(Math.min(state.doc.length, isA ? chunk.toA : chunk.toB)).number;
* }
* return builder.finish();
* }
* ```
*
* Ключевые отличия от CM:
* 1. Используем getChunks(state) вместо state.field(ChunkField) — public API
* 2. Unified view → side="b", поэтому всегда используем fromB/toB
* 3. Первая зона: CM начинает с line 1 без margin (collapseFrom = 1 при i=0),
* мы делаем то же самое для совместимости
* 4. Добавляем portionSize в PortionCollapseWidget
*
* @param state — текущее состояние EditorState
* @param margin — количество строк контекста (default 3)
* @param minSize — минимум строк для collapse (default 4)
* @param portionSize — строк за "Expand N" (default 100)
* @returns DecorationSet с collapsed зонами
*/
function buildPortionRanges(
state: EditorState,
margin: number,
minSize: number,
portionSize: number
): DecorationSet {
const result = getChunks(state);
const doc = state.doc;
// Если merge view ещё не инициализирован — пустые decorations
if (!result) return Decoration.none;
const chunks = result.chunks;
const builder = new RangeSetBuilder<Decoration>();
// Повторяем алгоритм CM's buildCollapsedRanges для unified view (side="b")
let prevLine = 1;
for (let i = 0; ; i++) {
const chunk = i < chunks.length ? chunks[i] : null;
// Для первой зоны (i=0): начинаем с line 1 БЕЗ margin (как CM)
// Для последующих: prevLine + margin
const collapseFrom = i ? prevLine + margin : 1;
// Конец зоны: строка перед началом следующего chunk - margin
// Или последняя строка документа (если chunk=null = зона после последнего chunk)
const collapseTo = chunk
? doc.lineAt(chunk.fromB).number - 1 - margin
: doc.lines;
const lines = collapseTo - collapseFrom + 1;
if (lines >= minSize) {
const from = doc.line(collapseFrom).from;
const to = doc.line(collapseTo).to;
const widget = new PortionCollapseWidget(lines, from, portionSize);
builder.add(
from,
to,
Decoration.replace({
widget,
block: true,
})
);
}
if (!chunk) break;
// prevLine = номер строки конца текущего chunk (для вычисления следующей зоны)
// Math.min(doc.length, chunk.toB) — защита от toB за пределами документа
// (CM Chunk: toB может быть "1 past the end of the last line")
prevLine = doc.lineAt(Math.min(doc.length, chunk.toB)).number;
}
return builder.finish();
}
Важно про chunks и позиции:
Chunks из getChunks(state) содержат:
fromA / toA— позиции в original документе (A)fromB / toB— позиции в текущем документе (B = EditorView's doc)
Для unified merge view side = "b" (или null), поэтому decorations в документе B. Используем fromB / toB.
Из типов @codemirror/merge:
class Chunk {
readonly fromA: number; // Start в original doc (character offset, 0-based)
readonly toA: number; // End в original doc (1 past end of last line, or = fromA if empty)
readonly fromB: number; // Start в current doc
readonly toB: number; // End в current doc (1 past end of last line, or = fromB if empty)
readonly changes: readonly Change[];
readonly precise: boolean;
get endA(): number; // fromA if empty, else end of last line (valid doc position)
get endB(): number; // fromB if empty, else end of last line (valid doc position)
}
ВАЖНО про toA/toB: Документация CM явно указывает:
"Note that
topositions may point past the end of the document. UseendA/endBif you need an end position that is certain to be a valid document position."
Поэтому Math.min(doc.length, chunk.toB) обязателен при использовании toB для doc.lineAt().
Позиции — это OFFSETS в документе (0-based character positions), НЕ номера строк. Конвертация:
const lineNumber = doc.lineAt(chunk.fromB).number; // 1-based line number
const lineStart = doc.line(lineNumber).from; // character offset
PortionCollapsedField — StateField
/**
* StateField хранящий текущие collapsed decorations.
*
* Обновляется при:
* 1. Изменении документа (docChanged) — ремаппинг позиций через map()
* 2. expandPortion effect — частичное раскрытие зоны
* 3. expandAllAtPos effect — полное раскрытие зоны
* 4. updateOriginalDoc effect (accept chunk) — полный rebuild
* 5. Lazy init: если create() вернул Decoration.none (chunks не готовы)
*
* Отличие от CM's CollapsedRanges:
* - CM использует .init() для начального build и map+filter в update
* - CM НЕ делает rebuild в update (полагается на reconfigure через compartment)
* - Мы делаем rebuild при accept/reject потому что portion expand state теряется
*/
const PortionCollapsedField = StateField.define<DecorationSet>({
create(state: EditorState): DecorationSet {
// getChunks(state) может вернуть null здесь если ChunkField ещё не инициализирован.
// Это нормально — buildPortionRanges обработает null и вернёт Decoration.none.
// Decorations будут построены при первом update (lazy init).
return buildPortionRanges(state, margin, minSize, portionSize);
},
update(value: DecorationSet, tr: Transaction): DecorationSet {
// === 1. Expand effects ===
let hasExpandEffect = false;
for (const effect of tr.effects) {
if (effect.is(expandPortion)) {
hasExpandEffect = true;
value = handleExpandPortion(value, effect.value, tr.state, minSize, portionSize);
}
if (effect.is(expandAllAtPos)) {
hasExpandEffect = true;
value = handleExpandAll(value, effect.value);
}
}
if (hasExpandEffect) {
return value;
}
// === 2. Accept chunk (updateOriginalDoc) → полный rebuild ===
// acceptChunk() dispatch'ит updateOriginalDoc effect БЕЗ docChanged.
// Это меняет original doc → chunks пересчитываются → наши decorations невалидны.
const hasUpdateOriginalDoc = tr.effects.some(e => e.is(updateOriginalDoc));
if (hasUpdateOriginalDoc) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
// === 3. Document changed (reject chunk, user editing) ===
if (tr.docChanged) {
// rejectChunk() делает docChanged (вставляет original текст).
// Chunks пересчитываются CM автоматически.
// Полный rebuild — корректнее чем map, т.к. chunks изменились.
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
// === 4. Lazy init: create() вернул Decoration.none ===
// Это происходит если getChunks() вернул null при create().
// После первой транзакции ChunkField уже инициализирован.
if (value === Decoration.none) {
const chunks = getChunks(tr.state);
if (chunks) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
}
return value;
},
provide(field): Extension {
return EditorView.decorations.from(field);
},
});
Импорт updateOriginalDoc:
import { updateOriginalDoc } from '@codemirror/merge';
Этот effect dispatch'ится при acceptChunk() — он обновляет original doc, что меняет chunks. Нужен полный rebuild decorations.
ВАЖНО: updateOriginalDoc уже импортируется в CodeMirrorDiffUtils.ts (строка 7), но НЕ реэкспортируется. Два варианта:
- Добавить реэкспорт в CodeMirrorDiffUtils.ts:
export { acceptChunk, getChunks, rejectChunk, updateOriginalDoc }; - Импортировать напрямую из
@codemirror/merge(рекомендуется — updateOriginalDoc это низкоуровневый effect, а не utility)
handleExpandPortion()
/**
* Обрабатывает частичное раскрытие collapsed зоны.
*
* Алгоритм:
* 1. Найти decoration range, содержащий pos (через DecorationSet.between)
* 2. Вычислить новые границы: сдвинуть from на count строк вниз
* 3. Если оставшихся строк < minSize — удалить decoration (= expand all)
* 4. Иначе — заменить decoration на новый с уменьшенным lineCount и обновлённым pos/from
*
* Использует DecorationSet.update({ filter, add }) вместо ручной итерации
* через RangeSetBuilder — это идиоматичнее и безопаснее.
*
* @param decorations — текущий DecorationSet
* @param value — { pos, count } из expandPortion effect
* @param state — текущий EditorState (после transaction)
* @param minSize — минимум строк для collapse
* @param portionSize — строк для "Expand N" кнопки
* @returns обновлённый DecorationSet
*/
function handleExpandPortion(
decorations: DecorationSet,
value: { pos: number; count: number },
state: EditorState,
minSize: number,
portionSize: number
): DecorationSet {
const { pos, count } = value;
const doc = state.doc;
// Поиск decoration, содержащей pos
let targetFrom = -1;
let targetTo = -1;
decorations.between(0, doc.length, (from, to) => {
if (from <= pos && pos <= to) {
targetFrom = from;
targetTo = to;
return false; // stop iteration
}
});
// pos не найден — возвращаем без изменений
if (targetFrom < 0) return decorations;
// Вычисляем строки
const fromLine = doc.lineAt(targetFrom).number;
const toLine = doc.lineAt(targetTo).number;
// Новый from = старый from + count строк
const newFromLine = fromLine + count;
const remainingLines = toLine - newFromLine + 1;
if (remainingLines < minSize) {
// Слишком мало строк осталось — убираем decoration целиком
return decorations.update({
filter: (from) => from !== targetFrom,
});
}
// Убираем старую decoration и добавляем новую с уменьшенным range
const newFrom = doc.line(newFromLine).from;
const widget = new PortionCollapseWidget(remainingLines, newFrom, portionSize);
return decorations.update({
filter: (from) => from !== targetFrom,
add: [
Decoration.replace({ widget, block: true }).range(newFrom, targetTo),
],
});
}
handleExpandAll()
/**
* Обрабатывает полное раскрытие collapsed зоны.
*
* Использует DecorationSet.update({ filter }) — идиоматичный CM подход.
* Аналогично тому, как CM's CollapsedRanges обрабатывает uncollapseUnchanged:
* deco.update({ filter: from => from != e.value })
*
* @param decorations — текущий DecorationSet
* @param pos — позиция из expandAllAtPos effect
* @returns обновлённый DecorationSet (без удалённой decoration)
*/
function handleExpandAll(
decorations: DecorationSet,
pos: number
): DecorationSet {
return decorations.update({
filter: (from, to) => !(from <= pos && pos <= to),
});
}
portionCollapseExtension() — реализация
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
const resolvedMargin = config?.margin ?? 3;
const resolvedMinSize = config?.minSize ?? 4;
const resolvedPortionSize = config?.portionSize ?? 100;
// Validate
if (resolvedMargin < 0) throw new Error('portionCollapse: margin must be >= 0');
if (resolvedMinSize < 1) throw new Error('portionCollapse: minSize must be >= 1');
if (resolvedPortionSize < 1) throw new Error('portionCollapse: portionSize must be >= 1');
// Замыкаем config значения для StateField
const margin = resolvedMargin;
const minSize = resolvedMinSize;
const portionSize = resolvedPortionSize;
// StateField с замыканием на config
const field = StateField.define<DecorationSet>({
create(state) {
return buildPortionRanges(state, margin, minSize, portionSize);
},
update(value, tr) {
// Полная реализация PortionCollapsedField (см. выше)
// с замыканием на margin, minSize, portionSize
// 1. Expand effects
let hasExpandEffect = false;
for (const effect of tr.effects) {
if (effect.is(expandPortion)) {
hasExpandEffect = true;
value = handleExpandPortion(value, effect.value, tr.state, minSize, portionSize);
}
if (effect.is(expandAllAtPos)) {
hasExpandEffect = true;
value = handleExpandAll(value, effect.value);
}
}
if (hasExpandEffect) return value;
// 2. Accept (updateOriginalDoc) → rebuild
if (tr.effects.some(e => e.is(updateOriginalDoc))) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
// 3. docChanged (reject, user edit) → rebuild
if (tr.docChanged) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
// 4. Lazy init
if (value === Decoration.none) {
const chunks = getChunks(tr.state);
if (chunks) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
}
return value;
},
provide(f) {
return EditorView.decorations.from(f);
},
});
return [field, portionCollapseTheme];
}
Дизайн-решение: closure vs Facet.
Config передаётся через closure в portionCollapseExtension(), а не через Facet. Причина: config не меняется после создания editor (только при dynamic reconfigure через Compartment). При reconfigure extension пересоздаётся целиком с новым config.
Модификация CodeMirrorDiffView.tsx
Файл: src/renderer/components/team/review/CodeMirrorDiffView.tsx
Новый prop
interface CodeMirrorDiffViewProps {
// ... существующие props ...
/**
* Использовать порционный collapse вместо CM's collapseUnchanged.
* Когда true: collapseUnchanged НЕ передаётся в mergeConfig.
* Вместо этого portionCollapseExtension добавляется отдельно.
* Default: false (обратная совместимость).
*/
usePortionCollapse?: boolean;
/**
* Количество строк за одно нажатие "Expand N".
* Используется только когда usePortionCollapse=true.
* Default: 100
*/
portionSize?: number;
}
Новый Compartment для portionCollapse
// Существующий:
const mergeCompartment = useRef(new Compartment());
// НОВЫЙ:
const portionCompartment = useRef(new Compartment());
buildMergeExtension: условное исключение collapseUnchanged
const buildMergeExtension = useCallback(
(collapse: boolean, margin: number): Extension => {
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
original,
highlightChanges: false,
gutter: true,
syntaxHighlightDeletions: true,
};
// ИЗМЕНЕНИЕ: collapseUnchanged добавляется ТОЛЬКО если portionCollapse выключен
if (collapse && !usePortionCollapse) {
mergeConfig.collapseUnchanged = {
margin,
minSize: 4,
};
}
// ... mergeControls logic без изменений ...
return unifiedMergeView(mergeConfig);
},
[original, showMergeControls, scrollToNextChunk, usePortionCollapse]
);
buildExtensions: добавление portionCollapseExtension
const buildExtensions = useCallback(() => {
const extensions: Extension[] = [
diffTheme,
lineNumbers(),
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
];
// ... существующие extensions (history, keymap, language, merge controls) ...
// Unified merge view (compartment) — ОБЯЗАТЕЛЬНО ПЕРВЫМ
// portionCollapse зависит от ChunkField из merge view
extensions.push(
mergeCompartment.current.of(
buildMergeExtension(collapseRef.current.enabled, collapseRef.current.margin)
)
);
// НОВОЕ: Portion collapse (отдельный compartment для dynamic reconfigure)
// ОБЯЗАТЕЛЬНО ПОСЛЕ merge view чтобы ChunkField был доступен в create()
extensions.push(
portionCompartment.current.of(
usePortionCollapse && collapseRef.current.enabled
? portionCollapseExtension({
margin: collapseRef.current.margin,
minSize: 4,
portionSize: portionSize ?? 100,
})
: []
)
);
return extensions;
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
Dynamic reconfigure: portionCollapse toggle
// Существующий effect для collapse toggle:
useEffect(() => {
const view = viewRef.current;
if (!view) return;
// Merge view reconfigure (без collapseUnchanged если portionCollapse включён)
view.dispatch({
effects: mergeCompartment.current.reconfigure(
buildMergeExtension(collapseUnchangedProp, collapseMargin)
),
});
// НОВОЕ: portionCollapse reconfigure
if (usePortionCollapse) {
view.dispatch({
effects: portionCompartment.current.reconfigure(
collapseUnchangedProp
? portionCollapseExtension({
margin: collapseMargin,
minSize: 4,
portionSize: portionSize ?? 100,
})
: [] // Collapse выключен — убираем portionCollapse decorations
),
});
}
}, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]);
Оптимизация: Два dispatch можно объединить в один:
view.dispatch({
effects: [
mergeCompartment.current.reconfigure(
buildMergeExtension(collapseUnchangedProp, collapseMargin)
),
...(usePortionCollapse
? [portionCompartment.current.reconfigure(
collapseUnchangedProp
? portionCollapseExtension({ margin: collapseMargin, minSize: 4, portionSize: portionSize ?? 100 })
: []
)]
: []),
],
});
Один dispatch = одна транзакция = один update всех StateField. Это важно для consistency.
Поведение toggle:
collapseUnchanged: true+usePortionCollapse: true= portion collapse ВКЛЮЧЁНcollapseUnchanged: false+usePortionCollapse: true= все зоны развёрнуты (portionCollapse off)collapseUnchanged: true+usePortionCollapse: false= CM's стандартный collapsecollapseUnchanged: false+usePortionCollapse: false= все зоны развёрнуты
Import
import {
portionCollapseExtension,
} from './portionCollapse';
Стили
Размещение: отдельная тема в portionCollapse.ts
Стили включаются как часть extension через portionCollapseTheme — инкапсулированы рядом с логикой, автоматически включаются/выключаются вместе с extension.
// В portionCollapse.ts
const portionCollapseTheme = EditorView.theme({
'.cm-portion-collapse': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 12px',
backgroundColor: 'var(--color-surface-raised)',
borderTop: '1px solid var(--color-border)',
borderBottom: '1px solid var(--color-border)',
minHeight: '28px',
cursor: 'default',
userSelect: 'none',
},
'.cm-portion-collapse-text': {
fontSize: '12px',
color: 'var(--color-text-muted)',
letterSpacing: '0.5px',
},
'.cm-portion-collapse-actions': {
display: 'flex',
alignItems: 'center',
gap: '6px',
},
'.cm-portion-expand-btn': {
padding: '2px 10px',
fontSize: '11px',
fontWeight: '500',
lineHeight: '18px',
color: 'var(--color-text-secondary)',
backgroundColor: 'transparent',
border: '1px solid var(--color-border)',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
color: 'var(--color-text)',
backgroundColor: 'rgba(255, 255, 255, 0.06)',
borderColor: 'var(--color-border-emphasis)',
},
'&:active': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
},
'.cm-portion-expand-all-btn': {
padding: '2px 10px',
fontSize: '11px',
fontWeight: '500',
lineHeight: '18px',
color: 'var(--color-text-muted)',
backgroundColor: 'transparent',
border: '1px solid transparent',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
color: 'var(--color-text-secondary)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
borderColor: 'var(--color-border)',
},
'&:active': {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
},
});
// Включается в extension
export function portionCollapseExtension(config?): Extension {
return [field, portionCollapseTheme];
}
Почему не в diffTheme:
- Инкапсулирует стили рядом с логикой
- Тема автоматически включается/выключается с extension
- Не загрязняет diffTheme стилями для feature, который может быть отключён
- CM dedup'ит тему если extension добавлена несколько раз
Как portionCollapse получает changed ranges
Доступные варианты
Вариант A: getChunks из @codemirror/merge (public API)
import { getChunks } from '@codemirror/merge';
getChunks(state) возвращает { chunks: readonly Chunk[], side: "a" | "b" | null } | null.
- Плюс: Официальный public API
- Плюс: Всегда актуальные chunks (обновляются при accept/reject)
- Минус: Может вернуть
nullесли merge view ещё не инициализирован - Минус:
sideв unified view ="b"(неnull— в документации CM: unified = side "b")
Вариант B: getChunks() из CodeMirrorDiffUtils.ts
import { getChunks } from './CodeMirrorDiffUtils';
Это реэкспорт getChunks из @codemirror/merge:
// CodeMirrorDiffUtils.ts, line 75
export { acceptChunk, getChunks, rejectChunk };
- Плюс: Уже используется в CodeMirrorDiffView.tsx
- Плюс: Единая точка импорта для всех merge utilities
- Минус: Тот же API что вариант A (просто реэкспорт)
Вариант C: самостоятельное вычисление через diff
import { Chunk, getOriginalDoc } from '@codemirror/merge';
function computeChangedRanges(state: EditorState): readonly Chunk[] {
const original = getOriginalDoc(state);
return Chunk.build(original, state.doc);
}
- Плюс: Не зависит от внутреннего ChunkField merge view
- Минус: Дублирование вычислений (chunks считаются дважды)
- Минус:
Chunk.buildможет быть дорогим на больших файлах
Рекомендация: Вариант B
Используем getChunks() из CodeMirrorDiffUtils.ts — уже проверенный и используемый в проекте. Это реэкспорт official API, но через единую точку проекта.
// portionCollapse.ts
import { getChunks } from './CodeMirrorDiffUtils';
Обработка null:
const result = getChunks(state);
if (!result) return Decoration.none; // Merge view ещё не готов
Порядок extensions и lazy init
getChunks вернёт null в create() если ChunkField ещё не инициализирован. CM вызывает create() для всех StateField при создании EditorState. Порядок вызова create() определяется порядком extensions.
Но даже если merge extension идёт первой в списке, ChunkField.init() может ещё не быть applied в момент create() нашего field, потому что init() работает как override для create() и применяется к тому же проходу инициализации.
Поэтому buildPortionRanges обрабатывает null от getChunks и возвращает Decoration.none. Lazy init в update() StateField исправляет это при первой же транзакции:
update(value, tr) {
// Lazy init: если create() вернул Decoration.none потому что chunks были null
if (value === Decoration.none) {
const chunks = getChunks(tr.state);
if (chunks) {
return buildPortionRanges(tr.state, margin, minSize, portionSize);
}
}
// ...
}
Когда вызовется первый update? При первом dispatch в EditorView. В CodeMirrorDiffView.tsx после создания view сразу идёт reconfigure для language (langCompartment.current.reconfigure(syncLang)) — это transaction, которая триггерит update всех StateField. Поэтому lazy init сработает практически мгновенно.
Edge-cases
1. portionCollapse + accept/reject
Проблема: При acceptChunk(view) CM dispatch'ит updateOriginalDoc effect (обновляет original doc). Это меняет ChunkField — collapsed зоны могут стать невалидными. При rejectChunk(view) CM dispatch'ит docChanged (заменяет текст в document B).
Решение: В update() StateField:
updateOriginalDoceffect detected → полный rebuild черезbuildPortionRanges()tr.docChanged→ полный rebuild черезbuildPortionRanges()
Существующие expanded зоны (пользователь уже нажал "Expand 100") теряются — все collapsed зоны пересоздаются.
Обоснование: Accept/reject — редкая операция. Потеря расширенных зон допустима (пользователь expand'ил чтобы посмотреть контекст, а после accept/reject контекст изменился). GitHub ведёт себя аналогично.
Отличие от CM: CM's CollapsedRanges при accept/reject НЕ делает rebuild в update(). CM полагается на reconfigure compartment для пересоздания decorations. Наш подход с rebuild в update() проще и не требует внешнего координатора.
2. Expand на границе файла
Проблема: Collapsed зона в начале файла (строки 1-100). "Expand 100" сдвинет from на 100 строк — зона исчезнет (0 строк). Это нормальное поведение.
Проблема: Collapsed зона в конце файла (строки 450-500). "Expand 100" запросит 100 строк, но доступно только 50.
Решение: handleExpandPortion вычисляет remainingLines. Если remainingLines < minSize — зона удаляется целиком (эквивалент "Expand All"). Widget показывает Expand {min(portionSize, lineCount)} если lineCount < portionSize.
Проверка в PortionCollapseWidget.toDOM():
if (this.lineCount > this.portionSize) {
// Кнопка "Expand {portionSize}"
} else {
// lineCount <= portionSize — показываем только "Expand All"
// (кнопка "Expand N" не создаётся — она бы expand'ила всё равно всё)
}
3. Файл целиком новый (isNewFile: true)
Проблема: Новый файл = весь контент "inserted". getChunks вернёт один chunk покрывающий весь файл. Unchanged зон НЕТ.
Решение: buildPortionRanges не создаёт decorations если нет промежутков между chunks. Цикл for (let i = 0; ; i++) проверяет lines >= minSize для каждой зоны — зон нет → builder.finish() возвращает пустой DecorationSet.
4. Reconfigure при toggle collapseUnchanged
Проблема: Пользователь включает/выключает collapse через ReviewToolbar toggle.
Решение: Dynamic reconfigure через portionCompartment:
- Toggle ON:
portionCompartment.reconfigure(portionCollapseExtension(config))— создаётся новый StateField с новыми decorations - Toggle OFF:
portionCompartment.reconfigure([])— StateField удаляется, decorations пропадают
Важно: При reconfigure ВСЕ expanded зоны сбрасываются (новый StateField = новые decorations). Это ожидаемо — toggle collapse = "пересоздать все collapsed зоны".
5. Конфликт с CM's collapseUnchanged
Проблема: Если случайно включены оба (collapseUnchanged в mergeConfig И portionCollapseExtension) — двойные Decoration.replace на одних и тех же зонах. Это приведёт к невалидным overlapping replace decorations.
Решение: buildMergeExtension проверяет usePortionCollapse и НЕ добавляет collapseUnchanged если portionCollapse включён. Дополнительно, в документации portionCollapseExtension явно указано: "НЕ совместима с collapseUnchanged".
Дополнительная защита: Можно добавить runtime check в buildPortionRanges:
// Если CM's collapseUnchanged уже создал decorations — пропускаем
// (проверка через наличие .cm-collapsedLines элементов в DOM)
Но это over-engineering — достаточно документации и проверки в buildMergeExtension.
6. Очень длинные файлы (10000+ строк)
Проблема: Много collapsed зон может замедлить DecorationSet operations.
Решение:
RangeSetBuilderсоздаёт balanced B-tree RangeSet эффективно (O(n))DecorationSet.update({ filter })= O(n) для фильтрации- Количество collapsed зон = chunks.length + 1 (максимум)
- Типичное количество chunks < 100, поэтому collapsed зон < 101
- CM RangeSet оптимизирован для тысяч ranges
7. Expand + docChanged одновременно (concurrent editing)
Проблема: Пользователь нажимает "Expand 100" в момент когда CM обрабатывает typing transaction.
Решение: CM гарантирует атомарность transactions. expandPortion effect будет в отдельной transaction. StateEffect.define({ map }) обеспечивает корректный ремаппинг позиций если document изменился между dispatch и apply.
Сигнатура map callback: (value: Value, mapping: ChangeDesc) => Value | undefined. Если map вернёт undefined — effect удаляется из transaction. Наш expandPortion.map всегда возвращает объект (mapPos не может вернуть undefined), поэтому effect всегда сохраняется.
8. updateDOM vs toDOM в PortionCollapseWidget
Проблема: CM вызывает updateDOM(dom, view) когда widget с eq() = false но того же типа. Можно обновить DOM вместо пересоздания.
Решение: НЕ реализуем updateDOM(). Причина:
- Widget пересоздаётся только при expand (нечасто)
eq()возвращает true если lineCount/portionSize не изменились — DOM не пересоздаётся- При rebuild decorations (accept/reject) все widgets пересоздаются в любом случае
- Сложность updateDOM (изменение текста + показ/скрытие кнопок) не оправдана для редкой операции
9. scrollbar высота при collapsed зонах
Проблема: CM вычисляет высоту scrollbar на основе видимого контента. Collapsed зоны скрывают строки — scrollbar может стать неточным.
Решение: Decoration.replace({ block: true }) корректно обрабатывается CM для расчёта scrollbar. CM использует estimatedHeight widget'а для приблизительной высоты. Наш widget возвращает фиксированную высоту (28px) — это высота видимого widget, не скрытого контента. CM's CollapseWidget возвращает 27px. Scrollbar корректно отражает видимую высоту документа.
10. eq() и pos — когда decoration перемещается
Проблема: После map(tr.changes) позиция decoration может сдвинуться. Виджет хранит pos — он станет stale.
Решение: eq() НЕ сравнивает pos. CM вызывает eq() только для decorations на ОДНОЙ позиции. Если позиция decoration изменилась после map — это другой range, CM пересоздаёт widget через toDOM(). Однако pos внутри виджета всё равно может быть stale если decoration map'нулась но eq() вернул true.
Но: pos используется только в onmousedown для dispatch effect. К моменту клика пользователя — document уже stable, и pos совпадает с from decoration (потому что виджет создавался с pos = from). Если map изменил from — CM пересоздаст виджет (eq = false из-за lineCount изменения или нового toDOM).
Для дополнительной надёжности можно использовать view.posAtDOM(container) вместо сохранённого this.pos:
expandBtn.onmousedown = (e) => {
e.preventDefault();
const pos = view.posAtDOM(container);
view.dispatch({ effects: expandPortion.of({ pos, count: this.portionSize }) });
};
Это паттерн из CM's CollapseWidget (view.posAtDOM(e.target)). Рекомендуется для robustness.
Проверка
Unit тесты
test/renderer/components/team/review/portionCollapse.test.ts
Тест-кейсы для buildPortionRanges:
- Нет chunks — getChunks returns null → Decoration.none
- Один chunk в середине — создаёт 2 collapsed зоны (до и после)
- Chunk в начале файла — одна collapsed зона после chunk
- Chunk в конце файла — одна collapsed зона до chunk
- Два chunks рядом — зона между ними < minSize → не collapse
- Два chunks далеко — зона между ними >= minSize → collapse с margin
- Весь файл — новый (1 chunk на весь файл) — пустой DecorationSet
- margin = 0 — collapse начинается сразу после chunk
- minSize = 1 — даже 1 строка сворачивается
- Первая зона (до первого chunk) — начинается с line 1 без margin (как CM)
Тест-кейсы для handleExpandPortion:
- Expand 100 строк из 247 — новая decoration с 147 строками, смещённый from
- Expand 100 строк из 103 — 3 строки осталось < minSize(4) → decoration удалена
- Expand 100 строк из 100 — lineCount == portionSize → decoration удалена (< minSize)
- pos не найден в decorations — decorations без изменений
Тест-кейсы для handleExpandAll:
- Expand all — decoration удалена — DecorationSet без этой decoration
- pos не найден — decorations без изменений
Тест-кейсы для PortionCollapseWidget:
- toDOM: lineCount > portionSize — 2 кнопки (Expand N + Expand All)
- toDOM: lineCount <= portionSize — 1 кнопка (только Expand All)
- toDOM: lineCount = 1 — "1 unchanged line" (singular)
- eq: same lineCount + portionSize — true
- eq: different lineCount — false
- ignoreEvent: MouseEvent — true
- ignoreEvent: KeyboardEvent — false
Тест-кейсы для StateField update:
- updateOriginalDoc effect (accept) — полный rebuild
- docChanged (reject) — полный rebuild
- expandPortion effect — partial expand
- expandAllAtPos effect — полное удаление
- Lazy init: Decoration.none → chunks available — rebuild
- No-op transaction — value unchanged
Ручная проверка
- Открыть файл с 500+ строк между изменениями
- Видна collapsed зона: "... 247 unchanged lines ..."
- Кнопка "Expand 100" → зона уменьшается до "... 147 unchanged lines ..."
- Повторный "Expand 100" → "... 47 unchanged lines ..." (только Expand All если <= 100)
- "Expand All" → зона полностью развёрнута
- Accept chunk → collapsed зоны пересчитаны
- Toggle collapse off/on → все зоны пересозданы (expanded зоны сброшены)
- Файл целиком новый → нет collapsed зон
- Маленький файл (< minSize между chunks) → нет collapsed зон
Визуальная проверка стилей
- Collapsed зона визуально совпадает с CM's
.cm-collapsedLines(bg, border, font) - Кнопки: hover → subtle highlight
- Кнопки: active → darker highlight
- Кнопки не ломают layout при resize окна
- Текст "N unchanged lines" корректно обновляется при expand
Файлы
| Файл | Тип | ~LOC |
|---|---|---|
src/renderer/components/team/review/portionCollapse.ts |
NEW | ~300 (StateField + Widget + helpers + theme) |
src/renderer/components/team/review/CodeMirrorDiffView.tsx |
MODIFY | ~40 (usePortionCollapse prop, compartment, buildExtensions) |
test/renderer/components/team/review/portionCollapse.test.ts |
NEW | ~300 (29 тест-кейсов) |
| Итого | 2 NEW + 1 MODIFY | ~640 |