addons/isl/src/stackEdit/chunkSelectState.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {RecordOf} from 'immutable';
b69ab319import type {FlattenLine, LineIdx} from '../linelog';
b69ab3110
b69ab3111import {Set as ImSet, List, Record} from 'immutable';
b69ab3112import {LRU, cachedMethod} from 'shared/LRU';
b69ab3113import {SelfUpdate} from 'shared/immutableExt';
b69ab3114import {FileStackState} from './fileStackState';
b69ab3115
b69ab3116/**
b69ab3117 * Represents selections of changes between 2 texts (`a` and `b`), optionally
b69ab3118 * the selection text can be edited free-form.
b69ab3119 *
b69ab3120 * Based on a 3-rev `FlattenLine`s representation:
b69ab3121 * - Rev 0: The `a` side (not editable by this `ChunkSelectState`).
b69ab3122 * - Rev 1: The selection (editable by this `ChunkSelectState`).
b69ab3123 * - Rev 2: The `b` side (not editable by this `ChunkSelectState`).
b69ab3124 *
b69ab3125 * Support operations:
b69ab3126 * - getLines: Obtain lines for rendering. See `SelectLine` for details.
b69ab3127 * - setSelectedLines: Set line selections. Only added or removed lines can be selected.
b69ab3128 * - getSelectedText: Obtain selected or edited text.
b69ab3129 * - setSelectedText: Set edited text. Useful for free-form editing.
b69ab3130 *
b69ab3131 * With free-from editing, there are two "special" cases:
b69ab3132 *
b69ab3133 * revs | meaning
b69ab3134 * -----------------------------
b69ab3135 * {0,1,2} | unchanged
b69ab3136 * {0,1} | deletion; not selected
b69ab3137 * {0,2} | special: extra deletion [*]
b69ab3138 * {0} | deletion; selected
b69ab3139 * {1,2} | insertion; selected
b69ab3140 * {1} | special: extra insertion
b69ab3141 * {2} | insertion; not selected
b69ab3142 *
b69ab3143 * [*]: LineLog.flatten() never produces {0,2}. It generates {0} and {2} as
b69ab3144 * separate lines. But `getLines()` post-processing might produce {0,2} lines.
b69ab3145 *
b69ab3146 * The callsite might want to treat the "special: extra insertion" like an
b69ab3147 * insertion with a different highlighting.
b69ab3148 *
b69ab3149 * This state does not care about collapsing unmodified lines, "context lines"
b69ab3150 * or "code expansion" states. They do not affect editing state, and belong to
b69ab3151 * the UI components.
b69ab3152 */
b69ab3153export class ChunkSelectState extends SelfUpdate<ChunkSelectRecord> {
b69ab3154 /**
b69ab3155 * Initialize ChunkSelectState from text `a` and `b`.
b69ab3156 *
b69ab3157 * If `selected` is `true`, then all changes are selected, selection result is `b`.
b69ab3158 * If `selected` is `false`, none of the changes are selected, selection result is `a`.
b69ab3159 * If `selected` is a string, then the selections are "inferred" from the string.
b69ab3160 *
b69ab3161 * If `normalize` is `true`, drop changes in `selected` that is not in `a` or `b`.
b69ab3162 */
b69ab3163 static fromText(
b69ab3164 a: string,
b69ab3165 b: string,
b69ab3166 selected: boolean | string,
b69ab3167 normalize = false,
b69ab3168 ): ChunkSelectState {
b69ab3169 const mid = selected === true ? b : selected === false ? a : selected;
b69ab3170 const fileStack = new FileStackState([a, mid, b]);
b69ab3171 let lines = fileStack.convertToFlattenLines().map(l => toLineBits(l));
b69ab3172 if (normalize) {
b69ab3173 lines = lines.filter(l => l.bits !== 0b101 && l.bits !== 0b010 && l.bits !== 0b000);
b69ab3174 }
b69ab3175 return new ChunkSelectState(ChunkSelectRecord({a, b, lines}));
b69ab3176 }
b69ab3177
b69ab3178 /** Get the text of the "a" side. */
b69ab3179 get a(): string {
b69ab3180 return this.inner.a;
b69ab3181 }
b69ab3182
b69ab3183 /** Get the text of the "b" side. */
b69ab3184 get b(): string {
b69ab3185 return this.inner.b;
b69ab3186 }
b69ab3187
b69ab3188 /**
b69ab3189 * Get the `SelectLine`s. Useful for rendering the lines.
b69ab3190 * See `SelectLine` for details.
b69ab3191 */
b69ab3192 getLines = cachedMethod(this.getLinesImpl, {cache: getLinesCache});
b69ab3193 private getLinesImpl(): Readonly<SelectLine[]> {
b69ab3194 let nextALine = 1;
b69ab3195 let nextBLine = 1;
b69ab3196 let nextSelLine = 1;
b69ab3197 let result: SelectLine[] = [];
b69ab3198
b69ab3199 // Modified lines to sort before appending to `result`.
b69ab31100 let buffer: SelectLine[] = [];
b69ab31101 const pushBuffer = () => {
b69ab31102 buffer.sort((a, b) => {
b69ab31103 // In this order: Deletion, insertion.
b69ab31104 const aOrder = bitsToOrder[a.bits];
b69ab31105 const bOrder = bitsToOrder[b.bits];
b69ab31106 if (aOrder !== bOrder) {
b69ab31107 return aOrder - bOrder;
b69ab31108 }
b69ab31109 return a.rawIndex - b.rawIndex;
b69ab31110 });
b69ab31111 // Merge "selected deletion + deselected insertion" with
b69ab31112 // the same content into "unselectable deletion".
b69ab31113 let nextDelIndex = 0;
b69ab31114 buffer.forEach((line, i) => {
b69ab31115 if (line.bits === 0b001 /* deselected insertion */) {
b69ab31116 // Try to find the matched "selected deletion" line.
b69ab31117 while (nextDelIndex < i) {
b69ab31118 const otherLine = buffer[nextDelIndex];
b69ab31119 if (otherLine.data === line.data && otherLine.bits === 0b100) {
b69ab31120 // Change otherLine to "unselectable deletion",
b69ab31121 // then remove this line.
b69ab31122 otherLine.bits = 0b101;
b69ab31123 otherLine.selected = null;
b69ab31124 otherLine.bLine = line.bLine;
b69ab31125 otherLine.sign = '!-';
b69ab31126 line.bits = 0;
b69ab31127 break;
b69ab31128 }
b69ab31129 nextDelIndex += 1;
b69ab31130 }
b69ab31131 }
b69ab31132 });
b69ab31133 buffer = buffer.filter(line => line.bits !== 0);
b69ab31134 result = result.concat(buffer);
b69ab31135 buffer = [];
b69ab31136 };
b69ab31137
b69ab31138 this.inner.lines.forEach((line, rawIndex) => {
b69ab31139 const bits = line.bits;
b69ab31140 let sign: Sign = '';
b69ab31141 let selected: boolean | null = null;
b69ab31142 let aLine: LineIdx | null = null;
b69ab31143 let bLine: LineIdx | null = null;
b69ab31144 let selLine: LineIdx | null = null;
b69ab31145 // eslint-disable-next-line no-bitwise
b69ab31146 if (bits >> 2 !== 0) {
b69ab31147 aLine = nextALine;
b69ab31148 nextALine += 1;
b69ab31149 }
b69ab31150 // eslint-disable-next-line no-bitwise
b69ab31151 if ((bits & 1) !== 0) {
b69ab31152 bLine = nextBLine;
b69ab31153 nextBLine += 1;
b69ab31154 }
b69ab31155 // eslint-disable-next-line no-bitwise
b69ab31156 if ((bits & 2) !== 0) {
b69ab31157 selLine = nextSelLine;
b69ab31158 nextSelLine += 1;
b69ab31159 }
b69ab31160 switch (bits) {
b69ab31161 case 0b001:
b69ab31162 sign = '+';
b69ab31163 selected = false;
b69ab31164 break;
b69ab31165 case 0b010:
b69ab31166 sign = '!+';
b69ab31167 break;
b69ab31168 case 0b011:
b69ab31169 sign = '+';
b69ab31170 selected = true;
b69ab31171 break;
b69ab31172 case 0b100:
b69ab31173 sign = '-';
b69ab31174 selected = true;
b69ab31175 break;
b69ab31176 case 0b101:
b69ab31177 sign = '!-';
b69ab31178 break;
b69ab31179 case 0b110:
b69ab31180 sign = '-';
b69ab31181 selected = false;
b69ab31182 break;
b69ab31183 case 0b111:
b69ab31184 break;
b69ab31185 }
b69ab31186 const selectLine: SelectLine = {
b69ab31187 rawIndex,
b69ab31188 aLine,
b69ab31189 bLine,
b69ab31190 selLine,
b69ab31191 sign,
b69ab31192 selected,
b69ab31193 bits,
b69ab31194 data: line.data,
b69ab31195 };
b69ab31196 if (sign === '') {
b69ab31197 pushBuffer();
b69ab31198 result.push(selectLine);
b69ab31199 } else {
b69ab31200 buffer.push(selectLine);
b69ab31201 }
b69ab31202 });
b69ab31203 pushBuffer();
b69ab31204 return result;
b69ab31205 }
b69ab31206
b69ab31207 /**
b69ab31208 * Get the line regions. By default, unchanged lines are collapsed.
b69ab31209 *
b69ab31210 * `config.contextLines` sets how many lines to expand around
b69ab31211 * changed or current lines.
b69ab31212 *
b69ab31213 * `config.expanded` and `config.caretLine` specify lines to
b69ab31214 * expanded.
b69ab31215 */
b69ab31216 getLineRegions(config?: {
b69ab31217 contextLines?: number;
b69ab31218 /** Line numbers on the "A" side to expand. */
b69ab31219 expandedALines: ImSet<number>;
b69ab31220 /** Line number on the "M" (selection) side to expand. */
b69ab31221 expandedSelLine?: number;
b69ab31222 }): Readonly<LineRegion[]> {
b69ab31223 const contextLines = config?.contextLines ?? 2;
b69ab31224 const lines = this.getLines();
b69ab31225 const expandedSelLine = config?.expandedSelLine ?? -1;
b69ab31226 const expandedALines = config?.expandedALines ?? ImSet();
b69ab31227 const regions: LineRegion[] = [];
b69ab31228
b69ab31229 // Figure out indexes of `lines` to collapse (skip).
b69ab31230 const collapsedLines = Array<boolean>(lines.length + contextLines).fill(true);
b69ab31231 lines.forEach((line, i) => {
b69ab31232 if (
b69ab31233 line.bits !== 0b111 ||
b69ab31234 expandedALines.has(line.aLine ?? -1) ||
b69ab31235 line.selLine === expandedSelLine
b69ab31236 ) {
b69ab31237 for (let j = i + contextLines; j >= 0 && j >= i - contextLines && collapsedLines[j]; j--) {
b69ab31238 collapsedLines[j] = false;
b69ab31239 }
b69ab31240 }
b69ab31241 });
b69ab31242
b69ab31243 // Scan through regions.
b69ab31244 let currentRegion: LineRegion | null = null;
b69ab31245 lines.forEach((line, i) => {
b69ab31246 const same = line.bits === 0b111;
b69ab31247 const collapsed = collapsedLines[i];
b69ab31248 if (currentRegion?.same === same && currentRegion?.collapsed === collapsed) {
b69ab31249 currentRegion.lines.push(line);
b69ab31250 } else {
b69ab31251 if (currentRegion !== null) {
b69ab31252 regions.push(currentRegion);
b69ab31253 }
b69ab31254 currentRegion = {lines: [line], same, collapsed};
b69ab31255 }
b69ab31256 });
b69ab31257 if (currentRegion !== null) {
b69ab31258 regions.push(currentRegion);
b69ab31259 }
b69ab31260
b69ab31261 return regions;
b69ab31262 }
b69ab31263
b69ab31264 /**
b69ab31265 * Get the text of selected lines. It is the editing result.
b69ab31266 *
b69ab31267 * Note: passing `getSelectedText` to `fromText` does not maintain the selection
b69ab31268 * state. For example, from an empty text to `1\n1\n1\n`. The user might select
b69ab31269 * the 1st, 2nd, or 3rd line. That's 3 different ways of selections with the same
b69ab31270 * `getSelectedText` output.
b69ab31271 */
b69ab31272 getSelectedText = cachedMethod(this.getSelectedTextImpl, {cache: getSelectedTextCache});
b69ab31273 private getSelectedTextImpl(): string {
b69ab31274 return (
b69ab31275 this.inner.lines
b69ab31276 // eslint-disable-next-line no-bitwise
b69ab31277 .filter(l => (l.bits & 0b010) !== 0)
b69ab31278 .map(l => l.data)
b69ab31279 .join('')
b69ab31280 );
b69ab31281 }
b69ab31282
b69ab31283 /**
b69ab31284 * Calculate the "inverse" of selected text. Useful for `revert -i` or "Discard".
b69ab31285 *
b69ab31286 * A Selected B | Inverse Note
b69ab31287 * 0 0 1 | 1 + not selected, preserve B
b69ab31288 * 0 1 0 | 0 = preserve B
b69ab31289 * 0 1 1 | 0 + selected, drop B, preserve A
b69ab31290 * 1 0 0 | 1 - selected, drop B, preserve A
b69ab31291 * 1 0 1 | 1 = preserve B
b69ab31292 * 1 1 0 | 0 - not selected, preserve B
b69ab31293 * 1 1 1 | 1 = preserve B
b69ab31294 */
b69ab31295 getInverseText(): string {
b69ab31296 return this.inner.lines
b69ab31297 .filter(l => [0b001, 0b100, 0b101, 0b111].includes(l.bits))
b69ab31298 .map(l => l.data)
b69ab31299 .join('');
b69ab31300 }
b69ab31301
b69ab31302 /**
b69ab31303 * Select or deselect lines.
b69ab31304 *
b69ab31305 * `selects` is a list of tuples. Each tuple has a `rawIndex` and whether that
b69ab31306 * line is selected or not.
b69ab31307 * Note if a line is deleted (sign is '-'), then selected means deleting that line.
b69ab31308 *
b69ab31309 * Note all lines are editable. Lines that are not editable are silently ignored.
b69ab31310 */
b69ab31311 setSelectedLines(selects: Array<[LineIdx, boolean]>): ChunkSelectState {
b69ab31312 const newLines = this.inner.lines.withMutations(mutLines => {
b69ab31313 let lines = mutLines;
b69ab31314 selects.forEach(([idx, selected]) => {
b69ab31315 const line = lines.get(idx);
b69ab31316 if (line === undefined) {
b69ab31317 return;
b69ab31318 }
b69ab31319 const {bits} = line;
b69ab31320 // eslint-disable-next-line no-bitwise
b69ab31321 const bits101 = bits & 0b101;
b69ab31322 if (bits101 === 0 || bits101 === 0b101) {
b69ab31323 // Not changed in v0 and v2 - ignore editing.
b69ab31324 return;
b69ab31325 }
b69ab31326 const oldSelected: boolean =
b69ab31327 // eslint-disable-next-line no-bitwise
b69ab31328 bits101 === 0b100 ? (bits & 0b010) === 0 : (bits & 0b010) !== 0;
b69ab31329 if (oldSelected !== selected) {
b69ab31330 // Update selection by toggling (xor) rev 1.
b69ab31331 // eslint-disable-next-line no-bitwise
b69ab31332 const newBits = bits ^ 0b010;
b69ab31333 const newLine = line.set('bits', newBits as Bits);
b69ab31334 lines = lines.set(idx, newLine);
b69ab31335 }
b69ab31336 });
b69ab31337 return lines;
b69ab31338 });
b69ab31339 return new ChunkSelectState(this.inner.set('lines', newLines));
b69ab31340 }
b69ab31341
b69ab31342 /**
b69ab31343 * Free-form edit selected text.
b69ab31344 *
b69ab31345 * Runs analysis to mark lines as selected. Consider only calling this once
b69ab31346 * when switching from free-form editing to line selections.
b69ab31347 */
b69ab31348 setSelectedText(text: string): ChunkSelectState {
b69ab31349 const {a, b} = this.inner;
b69ab31350 return ChunkSelectState.fromText(a, b, text);
b69ab31351 }
b69ab31352
b69ab31353 /**
b69ab31354 * The constructor is for internal use only.
b69ab31355 * Use static methods to construct `ChunkSelectState`.
b69ab31356 */
b69ab31357 constructor(record: ChunkSelectRecord) {
b69ab31358 super(record);
b69ab31359 }
b69ab31360}
b69ab31361
b69ab31362const getLinesCache = new LRU(1000);
b69ab31363const getSelectedTextCache = new LRU(1000);
b69ab31364
b69ab31365/** A line and its position on both sides, and selection state. */
b69ab31366export type SelectLine = {
b69ab31367 /** Index in the `lines` internal state. Starting from 0. */
b69ab31368 rawIndex: LineIdx;
b69ab31369
b69ab31370 /** Line index on the `a` side for rendering, or `null` if the line does not exist on the `a` side. */
b69ab31371 aLine: LineIdx | null;
b69ab31372
b69ab31373 /** Line index on the `b` side for rendering, or `null` if the line does not exist on the `b` side. */
b69ab31374 bLine: LineIdx | null;
b69ab31375
b69ab31376 /** Line index for "selected" lines, for rendering, or `null` if the line is not in the "selected" side. */
b69ab31377 selLine: LineIdx | null;
b69ab31378
b69ab31379 /** See `Sign` for description. */
b69ab31380 sign: Sign;
b69ab31381
b69ab31382 /**
b69ab31383 * Whether the line is selected or not. Only used when sign is '-' or '+'.
b69ab31384 * Note if a line is deleted (sign is '-'), then selected means deleting that line.
b69ab31385 */
b69ab31386 selected: boolean | null;
b69ab31387
b69ab31388 /** Line selection bits. */
b69ab31389 bits: Bits;
b69ab31390
b69ab31391 /** Content of the line. */
b69ab31392 data: string;
b69ab31393};
b69ab31394
b69ab31395/**
b69ab31396 * A contiguous range of lines that share same properties.
b69ab31397 * Properties include: "same for all version", "collapsed".
b69ab31398 */
b69ab31399export type LineRegion = {
b69ab31400 /** Lines in the region. */
b69ab31401 lines: SelectLine[];
b69ab31402
b69ab31403 /** If the region has the same content for all versions. */
b69ab31404 same: boolean;
b69ab31405
b69ab31406 /** If the region is collapsed. */
b69ab31407 collapsed: boolean;
b69ab31408};
b69ab31409
b69ab31410/** '-': deletion; '+': insertion; '': unchanged; '!+', '!-': forced insertion or deletion, not selectable. */
b69ab31411type Sign = '' | '+' | '-' | '!+' | '!-';
b69ab31412
b69ab31413type ChunkSelectProps = {
b69ab31414 a: string;
b69ab31415 b: string;
b69ab31416 lines: List<LineBitsRecord>;
b69ab31417};
b69ab31418const ChunkSelectRecord = Record<ChunkSelectProps>({a: '', b: '', lines: List()});
b69ab31419type ChunkSelectRecord = RecordOf<ChunkSelectProps>;
b69ab31420
b69ab31421type Bits = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
b69ab31422
b69ab31423/** Similar to `FlattenLine` but compress Set<Rev> into 3 bits for easier access. */
b69ab31424type LineBitsProps = {
b69ab31425 /** Line content including `\n` EOL. */
b69ab31426 data: string;
b69ab31427 /** Bitset. 0b100: left side; 0b001: right side; 0b010: editing / selecting text. */
b69ab31428 bits: Bits;
b69ab31429};
b69ab31430const LineBitsRecord = Record<LineBitsProps>({data: '', bits: 0});
b69ab31431type LineBitsRecord = RecordOf<LineBitsProps>;
b69ab31432
b69ab31433/**
b69ab31434 * Converts `FlattenLine` to `LineBits`.
b69ab31435 * `line.revs` (`ImSet<Rev>`) is converted to 3 bits. 0b100: rev 0; 0b010: rev 1; 0b001: rev 2
b69ab31436 */
b69ab31437function toLineBits(line: FlattenLine): LineBitsRecord {
b69ab31438 // eslint-disable-next-line no-bitwise
b69ab31439 const bits = line.revs.reduce((acc, rev) => acc | (4 >> rev), 0);
b69ab31440 return LineBitsRecord({data: line.data, bits: bits as Bits});
b69ab31441}
b69ab31442
b69ab31443const bitsToOrder = [
b69ab31444 0, // 0b000: unused
b69ab31445 2, // 0b001: normal insertion
b69ab31446 2, // 0b010: unselectable insertion
b69ab31447 2, // 0b011: normal insertion
b69ab31448 1, // 0b100: normal deletion
b69ab31449 1, // 0b101: unselectable deletion
b69ab31450 1, // 0b110: normal deletion
b69ab31451 0, // 0b111: normal
b69ab31452];