diff --git a/app/examples/sorting-algorithms.recho.js b/app/examples/sorting-algorithms.recho.js new file mode 100644 index 0000000..7f9eebe --- /dev/null +++ b/app/examples/sorting-algorithms.recho.js @@ -0,0 +1,274 @@ +/** + * @title Sorting Algorithms + * @author Luyu Cheng + * @created 2025-12-09 + * @pull_request 86 + * @github chengluyu + * @label Algorithm + */ + +/** + * ============================================================================ + * = Sorting Algorithms = + * ============================================================================ + */ + +//➜ ▁▂▃ +//➜ ▂▄▅▅▇▇███ +//➜ ▁█████████ +//➜ ▃▅▆▇▇██████████ +//➜ ▁▃▇████████████████ +//➜ ▆▇▇███████████████████ +//➜ ▁▄███████████████████████ +//➜ ▂▂▅▆█████████████████████████ +//➜ ▁▅█████████████████████████████ +//➜ ▃▆▇▇███████████████████████████████ +//➜ ▁▅▆▆▇███████████████████████████████████ +//➜ ▁▇████████████████████████████████████████ +//➜ ▄▇▇██████████████████████████████████████████ +//➜ +//➜ ────────────────────────────────────────────── +{ echo.set("compact", true); echo(renderNumbers(numbers.data, numbers.highlight)); } + +const maximum = recho.number(100); +const length = recho.number(46); +const swapsPerSecond = recho.number(43); + +recho.button("Insertion Sort", () => { + const numbers = randomArray(maximum, length); + setNumbers({data: numbers, highlight: null}); + play(insertionSortSwaps(numbers)); +}); + +recho.button("Selection Sort", () => { + const numbers = randomArray(maximum, length); + setNumbers({data: numbers, highlight: null}); + play(selectionSortSwaps(numbers)); +}); + +recho.button("Merge Sort", () => { + const numbers = randomArray(maximum, length); + setNumbers({data: numbers, highlight: null}); + play(mergeSortSwaps(numbers)); +}); + +recho.button("Quick Sort", () => { + const numbers = randomArray(maximum, length); + setNumbers({data: numbers, highlight: null}); + play(quickSortSwaps(numbers)); +}); + +function insertionSortSwaps(arr) { + const swaps = []; + const copy = [...arr]; // work with a copy + for (let i = 1; i < copy.length; i++) { + let j = i; + // Move element at position i to its correct position + while (j > 0 && copy[j] < copy[j - 1]) { + swaps.push([j - 1, j]); + // Swap elements + [copy[j - 1], copy[j]] = [copy[j], copy[j - 1]]; + j--; + } + } + return swaps; +} + +function selectionSortSwaps(arr) { + const swaps = []; + const copy = [...arr]; + + for (let i = 0; i < copy.length - 1; i++) { + let minIndex = i; + // Find minimum element in remaining unsorted portion + for (let j = i + 1; j < copy.length; j++) { + swaps.push([j]) + if (copy[j] < copy[minIndex]) { + minIndex = j; + } + } + // Swap if minimum is not at current position + if (minIndex !== i) { + swaps.push([i, minIndex]); + [copy[i], copy[minIndex]] = [copy[minIndex], copy[i]]; + } + } + + return swaps; +} + +function mergeSortSwaps(arr) { + const swaps = []; + const copy = [...arr]; + + function merge(left, mid, right) { + const leftArr = copy.slice(left, mid + 1); + const rightArr = copy.slice(mid + 1, right + 1); + + let i = 0, j = 0, k = left; + + while (i < leftArr.length && j < rightArr.length) { + if (leftArr[i] <= rightArr[j]) { + copy[k] = leftArr[i]; + i++; + } else { + // Element from right half needs to move before elements from left half + // This represents multiple swaps in the original array + copy[k] = rightArr[j]; + // Record swaps: right element moves past remaining left elements + const sourcePos = mid + 1 + j; + for (let pos = sourcePos; pos > k; pos--) { + swaps.push([pos - 1, pos]); + } + j++; + } + k++; + } + + // Copy remaining elements (no swaps needed as they're already in place) + while (i < leftArr.length) { + copy[k] = leftArr[i]; + i++; + k++; + } + while (j < rightArr.length) { + copy[k] = rightArr[j]; + j++; + k++; + } + } + + function mergeSort(left, right) { + if (left < right) { + const mid = Math.floor((left + right) / 2); + mergeSort(left, mid); + mergeSort(mid + 1, right); + merge(left, mid, right); + } + } + + mergeSort(0, copy.length - 1); + return swaps; +} + +function quickSortSwaps(arr) { + const swaps = []; + const copy = [...arr]; + + function medianOfThree(left, right) { + const mid = Math.floor((left + right) / 2); + const a = copy[left], b = copy[mid], c = copy[right]; + + // Find median and return its index + if ((a <= b && b <= c) || (c <= b && b <= a)) return mid; + if ((b <= a && a <= c) || (c <= a && a <= b)) return left; + return right; + } + + function partition(left, right) { + // Choose pivot using median-of-three + const pivotIndex = medianOfThree(left, right); + + // Move pivot to end + if (pivotIndex !== right) { + swaps.push([pivotIndex, right]); + [copy[pivotIndex], copy[right]] = [copy[right], copy[pivotIndex]]; + } + + const pivot = copy[right]; + let i = left - 1; + + for (let j = left; j < right; j++) { + if (copy[j] <= pivot) { + i++; + if (i !== j) { + swaps.push([i, j]); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + } + } + + // Move pivot to its final position + i++; + if (i !== right) { + swaps.push([i, right]); + [copy[i], copy[right]] = [copy[right], copy[i]]; + } + + return i; + } + + function quickSort(left, right) { + if (left < right) { + const pivotIndex = partition(left, right); + quickSort(left, pivotIndex - 1); + quickSort(pivotIndex + 1, right); + } + } + + quickSort(0, copy.length - 1); + return swaps; +} + +function play(swaps) { + if (playing !== null) { + clearInterval(playing); + } + let current = 0; + const id = setInterval(takeStep, Math.floor(1000 / swapsPerSecond)); + setPlaying(id); + function takeStep() { + if (current >= swaps.length) { + clearInterval(playing); + setPlaying(null); + return; + } + const swap = swaps[current]; + if (swap.length === 2) { + const [left, right] = swap; + setNumbers(({ data }) => { + const cloned = structuredClone(data); + const temp = cloned[left]; + cloned[left] = cloned[right]; + cloned[right] = temp; + return { data: cloned, highlight: null }; + }); + } else if (swap.length === 1) { + const [index] = swap; + setNumbers(({ data }) => { + return { data, highlight: index}; + }); + } + current++; + } +} + +function randomArray(maximum, length) { + const buffer = []; + const gen = d3.randomInt(maximum); + for (let i = 0; i < length; i++) { + buffer.push(gen()); + } + return buffer; +} + +function renderNumbers(numbers, highlight) { + const min = d3.min(numbers), max = d3.max(numbers); + const segmentCount = (max >>> 3) + ((max & 7) === 0 ? 0 : 1); + const buffer = d3.transpose(numbers.map((n, i) => { + const head = (n & 7) === 0 ? "" : blocks[n & 7]; + const body = fullBlock.repeat(n >>> 3); + const padding = " ".repeat(segmentCount - (head.length + body.length)); + const ending = i === highlight ? "╻┸" : " ─"; + return padding + head + body + ending; + })); + return buffer.map(xs => xs.join("")).join("\n"); +} + +const [playing, setPlaying] = recho.state(null); +const [numbers, setNumbers] = recho.state({ data: [], highlight: null }) + +const blocks = Array.from(" ▁▂▃▄▅▆▇"); +const fullBlock = "█"; + +const d3 = recho.require("d3"); diff --git a/editor/blockIndicator.ts b/editor/blockIndicator.ts new file mode 100644 index 0000000..2425a5f --- /dev/null +++ b/editor/blockIndicator.ts @@ -0,0 +1,83 @@ +import {GutterMarker, gutter} from "@codemirror/view"; +import {BlockMetadata} from "./blocks/BlockMetadata.js"; +import {blockMetadataField} from "./blockMetadata.ts"; + +export class BlockIndicator extends GutterMarker { + constructor(private className: string) { + super(); + } + toDOM() { + const div = document.createElement("div"); + div.className = this.className; + return div; + } +} + +const indicatorMarkers = { + output: { + head: new BlockIndicator("cm-block-indicator output head"), + tail: new BlockIndicator("cm-block-indicator output tail"), + sole: new BlockIndicator("cm-block-indicator output head tail"), + body: new BlockIndicator("cm-block-indicator output"), + }, + source: { + head: new BlockIndicator("cm-block-indicator source head"), + tail: new BlockIndicator("cm-block-indicator source tail"), + sole: new BlockIndicator("cm-block-indicator source head tail"), + body: new BlockIndicator("cm-block-indicator source"), + }, + error: { + head: new BlockIndicator("cm-block-indicator error head"), + tail: new BlockIndicator("cm-block-indicator error tail"), + sole: new BlockIndicator("cm-block-indicator error head tail"), + body: new BlockIndicator("cm-block-indicator error"), + }, +}; + +export const blockIndicator = gutter({ + class: "cm-blockIndicators", + lineMarker(view, line) { + const blocks = view.state.field(blockMetadataField, false); + if (blocks === undefined) return null; + const index = findEnclosingBlock(blocks, line.from); + if (index === null) return null; + const currentLine = view.state.doc.lineAt(line.from).number; + const sourceFirstLine = view.state.doc.lineAt(blocks[index].source.from).number; + const group = blocks[index].error + ? indicatorMarkers.error + : currentLine < sourceFirstLine + ? indicatorMarkers.output + : indicatorMarkers.source; + const blockFirstLine = view.state.doc.lineAt(blocks[index].from).number; + const blockLastLine = view.state.doc.lineAt(blocks[index].to).number; + if (blockFirstLine === currentLine) { + return blockLastLine === currentLine ? group.sole : group.head; + } else if (blockLastLine === currentLine) { + return group.tail; + } else { + return group.body; + } + }, + initialSpacer() { + return indicatorMarkers.source.body; + }, +}); + +function findEnclosingBlock(blocks: BlockMetadata[], pos: number): number | null { + let left = 0; + let right = blocks.length - 1; + + while (left <= right) { + const middle = (left + right) >>> 1; + const pivot = blocks[middle]; + if (pos < pivot.from) { + right = middle - 1; + } else if (pos > pivot.to) { + left = middle + 1; + } else { + return middle; + } + } + + return null; +} diff --git a/editor/blockMetadata.ts b/editor/blockMetadata.ts new file mode 100644 index 0000000..0614354 --- /dev/null +++ b/editor/blockMetadata.ts @@ -0,0 +1,169 @@ +import {StateField, StateEffect, Transaction} from "@codemirror/state"; +import {blockRangeLength, findAffectedBlockRange, getOnlyOneBlock} from "../lib/blocks.ts"; +import {detectBlocksWithinRange} from "../lib/blocks/detect.ts"; +import {syntaxTree} from "@codemirror/language"; +import {MaxHeap} from "../lib/containers/heap.ts"; +import {BlockMetadata} from "./blocks/BlockMetadata.ts"; +import {deduplicateNaive} from "./blocks/dedup.ts"; + +/** + * Update block metadata according to the given transaction. + * + * @param oldBlocks the current blocks + * @param tr the editor transaction + */ +function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): BlockMetadata[] { + // If the transaction does not change the document, then we return early. + if (!tr.docChanged) return oldBlocks; + + const userEvent = tr.annotation(Transaction.userEvent); + if (userEvent) { + console.group(`updateBlocks (${userEvent})`); + } else { + console.groupCollapsed(`updateBlocks`); + } + + // Collect all ranges that need to be rescanned + type ChangedRange = {oldFrom: number; oldTo: number; newFrom: number; newTo: number}; + const changedRanges: ChangedRange[] = []; + tr.changes.iterChangedRanges((oldFrom, oldTo, newFrom, newTo) => { + changedRanges.push({oldFrom, oldTo, newFrom, newTo}); + }); + + if (changedRanges.length === 0) { + console.log("No changes detected"); + console.groupEnd(); + return oldBlocks; + } + + /** + * Keep track of all blocks that are affected by the change. They will not be + * added to the array of new blocks. + */ + const affectedBlocks = new Set(); + + const newlyCreatedBlocks = new MaxHeap( + (block) => block.from, + (a, b) => b - a, + ); + + // Process changed ranges one by one, because ranges are disjoint. + for (const {oldFrom, oldTo, newFrom, newTo} of changedRanges) { + if (oldFrom === oldTo) { + if (newFrom === oldFrom) { + console.groupCollapsed(`Insert ${newTo - newFrom} characters at ${oldFrom}`); + } else { + console.groupCollapsed(`Insert ${newTo - newFrom} characters: ${oldFrom} -> ${newFrom}-${newTo}`); + } + } else { + console.groupCollapsed(`Update: ${oldFrom}-${oldTo} -> ${newFrom}-${newTo}`); + } + + // Step 1: Find the blocks that are affected by the change. + + const affectedBlockRange = findAffectedBlockRange(oldBlocks, oldFrom, oldTo); + + console.log(`Affected block range: ${affectedBlockRange[0]}-${affectedBlockRange[1]}`); + + // Add the affected blocks to the set. + for (let i = affectedBlockRange[0] ?? 0, n = affectedBlockRange[1] ?? oldBlocks.length; i < n; i++) { + affectedBlocks.add(oldBlocks[i]!); + } + + // Check a corner case where the affected block range is empty but there are blocks. + if (blockRangeLength(oldBlocks.length, affectedBlockRange) === 0 && oldBlocks.length > 0) { + reportError("This should never happen"); + } + + // Now, we are going to compute the range which should be re-parsed. + const reparseFrom = affectedBlockRange[0] === null ? 0 : oldBlocks[affectedBlockRange[0]]!.from; + const reparseTo = affectedBlockRange[1] === null ? tr.state.doc.length : oldBlocks[affectedBlockRange[1] - 1]!.to; + const newBlocks = detectBlocksWithinRange(syntaxTree(tr.state), tr.state.doc, reparseFrom, reparseTo); + + // If only one block is affected and only one new block is created, we can + // simply inherit the attributes from the old block. + const soleBlockIndex = getOnlyOneBlock(oldBlocks.length, affectedBlockRange); + if (typeof soleBlockIndex === "number" && newBlocks.length === 1) { + newBlocks[0]!.attributes = oldBlocks[soleBlockIndex]!.attributes; + } + + // Add new blocks to the heap, which sorts blocks by their `from` position. + for (let i = 0, n = newBlocks.length; i < n; i++) { + newlyCreatedBlocks.insert(newBlocks[i]!); + } + + console.groupEnd(); + } + + // Step 3: Combine the array of old blocks and the heap of new blocks. + + const newBlocks: BlockMetadata[] = []; + + for (let i = 0, n = oldBlocks.length; i < n; i++) { + const oldBlock = oldBlocks[i]!; + + // Skip affected blocks, as they have been updated. + if (affectedBlocks.has(oldBlock)) continue; + + const newBlock = oldBlock.map(tr); + + // Peek the heap. Check the positional relationship between its foremost + // block and the current old block. + + while (newlyCreatedBlocks.nonEmpty() && newlyCreatedBlocks.peek.from < newBlock.from) { + newBlocks.push(newlyCreatedBlocks.peek); + newlyCreatedBlocks.extractMax(); + } + + // At this point, the heap is either empty or the foremost block is after + // the current old block's `from` position. + + newBlocks.push(newBlock); + } + + // In the end, push any remaining blocks from the heap. + while (newlyCreatedBlocks.nonEmpty()) { + newBlocks.push(newlyCreatedBlocks.peek); + newlyCreatedBlocks.extractMax(); + } + + console.log("New blocks:", newBlocks); + + const deduplicatedBlocks = deduplicateNaive(newBlocks); + + console.groupEnd(); + return deduplicatedBlocks; +} + +export const blockMetadataEffect = StateEffect.define(); + +export const blockMetadataField = StateField.define({ + create() { + return []; + }, + update(blocks, tr) { + // Find if the block attributes effect is present. + let blocksFromEffect: BlockMetadata[] | null = null; + for (const effect of tr.effects) { + if (effect.is(blockMetadataEffect)) { + blocksFromEffect = effect.value; + break; + } + } + + if (blocksFromEffect === null) { + // If the block attributes effect is not present, then this transaction + // is made by the user, we need to update the block attributes accroding + // to the latest syntax tree. + return updateBlocks(blocks, tr); + } else { + // Otherwise, we need to update the block attributes according to the + // metadata sent from the runtime. Most importantly, we need to translate + // the position of each block after the changes has been made. + console.log(blocksFromEffect); + return blocksFromEffect.map((block) => block.map(tr)); + } + }, +}); + +export const blockMetadataExtension = blockMetadataField.extension; diff --git a/editor/blocks/BlockMetadata.ts b/editor/blocks/BlockMetadata.ts new file mode 100644 index 0000000..497d5e1 --- /dev/null +++ b/editor/blocks/BlockMetadata.ts @@ -0,0 +1,53 @@ +import type {Transaction} from "@codemirror/state"; + +export type Range = {from: number; to: number}; + +export class BlockMetadata { + /** + * Create a new `BlockMetadata` instance. + * @param name a descriptive name of this block + * @param output the range of the output region + * @param source the range of the source region + * @param attributes any user-customized attributes of this block + * @param error whether this block has an error + */ + public constructor( + public readonly name: string, + public readonly output: Range | null, + public readonly source: Range, + public attributes: Record = {}, + public error: boolean = false, + ) {} + + /** + * Get the start position (inclusive) of this block. + */ + get from() { + return this.output?.from ?? this.source.from; + } + + /** + * Get the end position (exclusive) of this block. + */ + get to() { + return this.source.to; + } + + public map(tr: Transaction): BlockMetadata { + // If no changes were made to the document, return the current instance. + if (!tr.docChanged) return this; + + // Otherwise, map the output and source ranges. + const output = this.output + ? { + from: tr.changes.mapPos(this.output.from, -1), + to: tr.changes.mapPos(this.output.to, 1), + } + : null; + const source = { + from: tr.changes.mapPos(this.source.from, 1), + to: tr.changes.mapPos(this.source.to, 1), + }; + return new BlockMetadata(this.name, output, source, this.attributes, this.error); + } +} diff --git a/editor/blocks/dedup.ts b/editor/blocks/dedup.ts new file mode 100644 index 0000000..2198192 --- /dev/null +++ b/editor/blocks/dedup.ts @@ -0,0 +1,28 @@ +import type {BlockMetadata} from "./BlockMetadata.ts"; + +export function deduplicateNaive(blocks: BlockMetadata[]): BlockMetadata[] { + const newBlocks: BlockMetadata[] = []; + let last: BlockMetadata | null = null; + + for (let i = 0, n = blocks.length; i < n; i++) { + const block = blocks[i]!; + if (last === null || last.to <= block.from) { + newBlocks.push((last = block)); + } else if (last.from === block.from && last.to === block.to) { + // The block is a duplicate of the last block, so we skip it. + continue; + } else if (last.source.from === block.source.from && last.source.to === block.source.to) { + // The block is a duplicate of the last block, but doesn't have an output. + // We can also skip it. + continue; + } else { + console.error("Found a weird overlap."); + console.log("newBlocks", newBlocks.slice()); + console.log("i", i); + console.log("last", last); + console.log("block", block); + } + } + + return newBlocks; +} diff --git a/editor/decoration.js b/editor/decoration.js index 6d8d038..585150f 100644 --- a/editor/decoration.js +++ b/editor/decoration.js @@ -1,20 +1,72 @@ import {Decoration, ViewPlugin, ViewUpdate, EditorView} from "@codemirror/view"; import {outputLinesField} from "./outputLines"; -import {RangeSetBuilder} from "@codemirror/state"; +import {blockMetadataField} from "./blockMetadata"; +import {RangeSet, RangeSetBuilder} from "@codemirror/state"; const highlight = Decoration.line({attributes: {class: "cm-output-line"}}); const errorHighlight = Decoration.line({attributes: {class: "cm-output-line cm-error-line"}}); +const compactLineDecoration = Decoration.line({attributes: {class: "cm-output-line cm-compact-line"}}); +const debugGreenDecoration = Decoration.mark({attributes: {class: "cm-debug-mark green"}}); +const debugRedDecoration = Decoration.mark({attributes: {class: "cm-debug-mark red"}}); +const debugBlueDecoration = Decoration.mark({attributes: {class: "cm-debug-mark blue"}}); // const linePrefix = Decoration.mark({attributes: {class: "cm-output-line-prefix"}}); // const lineContent = Decoration.mark({attributes: {class: "cm-output-line-content"}}); -function createWidgets(lines) { - const builder = new RangeSetBuilder(); +function createWidgets(lines, blockMetadata, state) { + // Build the range set for output lines. + const builder1 = new RangeSetBuilder(); + // Add output line decorations for (const {from, type} of lines) { - if (type === "output") builder.add(from, from, highlight); - else if (type === "error") builder.add(from, from, errorHighlight); + if (type === "output") builder1.add(from, from, highlight); + else if (type === "error") builder1.add(from, from, errorHighlight); // builder.add(from, from + 3, linePrefix); // builder.add(from + 4, to, lineContent); } + const set1 = builder1.finish(); + + // Build the range set for block attributes. + // console.groupCollapsed("Decorations for block attributes"); + const builder2 = new RangeSetBuilder(); + // Add block attribute decorations + for (const {output, attributes} of blockMetadata) { + if (output === null) continue; + // Apply decorations to each line in the block range + const startLine = state.doc.lineAt(output.from); + const endLine = state.doc.lineAt(output.to); + // console.log(`Make lines from ${startLine.number} to ${endLine.number} compact`); + if (attributes.compact === true) { + for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { + const line = state.doc.line(lineNum); + builder2.add(line.from, line.from, compactLineDecoration); + } + } + } + const set2 = builder2.finish(); + // console.groupEnd(); + + // Range sets are required to be sorted. Fortunately, they provide a method + // to merge multiple range sets into a single sorted range set. + return RangeSet.join([set1, set2]); +} + +function createDebugMarks(blockMetadata, state) { + // Build mark decorations separately from line decorations to avoid conflicts + const builder = new RangeSetBuilder(); + + for (const {output, source} of blockMetadata) { + // Add red marks for output ranges + if (output !== null && output.from < output.to) { + // console.log(`Adding red decoration for output: ${output.from}-${output.to}`); + builder.add(output.from, output.to, debugRedDecoration); + } + + // Add green marks for source ranges + if (source.from < source.to) { + // console.log(`Adding green decoration for source: ${source.from}-${source.to}`); + builder.add(source.from, source.to, debugGreenDecoration); + } + } + return builder.finish(); } @@ -28,14 +80,40 @@ export const outputDecoration = ViewPlugin.fromClass( /** @param {EditorView} view */ constructor(view) { - this.#decorations = createWidgets(view.state.field(outputLinesField)); + const outputLines = view.state.field(outputLinesField); + const blockMetadata = view.state.field(blockMetadataField); + this.#decorations = createWidgets(outputLines, blockMetadata, view.state); } /** @param {ViewUpdate} update */ update(update) { const newOutputLines = update.state.field(outputLinesField); + const blockMetadata = update.state.field(blockMetadataField); // A possible optimization would be to only update the changed lines. - this.#decorations = createWidgets(newOutputLines); + this.#decorations = createWidgets(newOutputLines, blockMetadata, update.state); + } + }, + {decorations: (v) => v.decorations}, +); + +export const debugDecoration = ViewPlugin.fromClass( + class { + #decorations; + + get decorations() { + return this.#decorations; + } + + /** @param {EditorView} view */ + constructor(view) { + const blockMetadata = view.state.field(blockMetadataField); + this.#decorations = createDebugMarks(blockMetadata, view.state); + } + + /** @param {ViewUpdate} update */ + update(update) { + const blockMetadata = update.state.field(blockMetadataField); + this.#decorations = createDebugMarks(blockMetadata, update.state); } }, {decorations: (v) => v.decorations}, diff --git a/editor/index.css b/editor/index.css index f86553a..5058717 100644 --- a/editor/index.css +++ b/editor/index.css @@ -143,6 +143,61 @@ /*background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3E%3Cpath fill='%237d7d7d' fill-opacity='0.4' d='M1 3h1v1H1V3zm2-2h1v1H3V1z'%3E%3C/path%3E%3C/svg%3E");*/ } + .cm-output-line.cm-compact-line { + line-height: 1; + } + + .cm-debug-mark { + &.red { + background-color: #fbbf2440; + } + &.green { + background-color: #009a5753; + } + &.blue { + background-color: #0088f653; + } + } + + .cm-blockIndicators { + padding-left: 0.25rem; + + --gap-between-blocks: 2px; + } + + .cm-gutterElement .cm-block-indicator { + width: 0.25rem; + height: 100%; + + &.output { + background: #72aa006e; + } + + &.source { + background: #1f180021; + } + + &.error { + background: #df2600d1; + } + + &.head { + height: calc(100% - var(--gap-between-blocks)); + /*border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem;*/ + + &:not(.tail) { + transform: translate(0, var(--gap-between-blocks)); + } + } + + &.tail { + height: calc(100% - var(--gap-between-blocks)); + /*border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem;*/ + } + } + /* We can't see background color, which is conflict with selection background color. * So we use svg pattern to simulate the background color. */ diff --git a/editor/index.js b/editor/index.js index 26275ff..6e3bacc 100644 --- a/editor/index.js +++ b/editor/index.js @@ -9,14 +9,17 @@ import {indentWithTab} from "@codemirror/commands"; import {browser} from "globals"; import * as eslint from "eslint-linter-browserify"; import {createRuntime} from "../runtime/index.js"; -import {outputDecoration} from "./decoration.js"; +import {outputDecoration, debugDecoration} from "./decoration.js"; import {outputLines} from "./outputLines.js"; +import {blockMetadataExtension} from "./blockMetadata.ts"; // import {outputProtection} from "./protection.js"; import {dispatch as d3Dispatch} from "d3-dispatch"; import {controls} from "./controls/index.js"; import {rechoCompletion} from "./completion.js"; import {docStringTag} from "./docStringTag.js"; import {commentLink, commentLinkClickHandler} from "./commentLink.js"; +import {blockIndicator} from "./blockIndicator.ts"; +import {lineNumbers} from "@codemirror/view"; // @see https://github.com/UziTech/eslint-linter-browserify/blob/master/example/script.js // @see https://codemirror.net/examples/lint/ @@ -33,14 +36,17 @@ const eslintConfig = { }; export function createEditor(container, options) { - const {code, onError} = options; + const {code, onError, extensions = []} = options; const dispatcher = d3Dispatch("userInput"); const runtimeRef = {current: null}; + const myBasicSetup = Array.from(basicSetup); + myBasicSetup.splice(2, 0, blockIndicator); + const state = EditorState.create({ doc: code, extensions: [ - basicSetup, + myBasicSetup, javascript(), githubLightInit({ styles: [ @@ -68,7 +74,9 @@ export function createEditor(container, options) { ]), javascriptLanguage.data.of({autocomplete: rechoCompletion}), outputLines, + blockMetadataExtension, outputDecoration, + debugDecoration, controls(runtimeRef), // Disable this for now, because it prevents copying/pasting the code. // outputProtection(), @@ -76,6 +84,7 @@ export function createEditor(container, options) { commentLink, commentLinkClickHandler, linter(esLint(new eslint.Linter(), eslintConfig)), + ...extensions, ], }); @@ -91,9 +100,13 @@ export function createEditor(container, options) { window.addEventListener("openlink", onOpenLink); } - function dispatch(changes) { + function dispatch({changes, effects}) { // Mark this transaction as from runtime so that it will not be filtered out. - view.dispatch({changes, annotations: [Transaction.remote.of("runtime")]}); + view.dispatch({ + changes, + effects, + annotations: [Transaction.remote.of(true)], + }); } function onChange(update) { diff --git a/eslint.config.mjs b/eslint.config.mjs index f812d4a..f4a2295 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,10 +3,17 @@ import {defineConfig} from "eslint/config"; import eslintConfigPrettier from "eslint-config-prettier/flat"; import reactPlugin from "eslint-plugin-react"; import hooksPlugin from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; export default defineConfig([ { - files: ["editor/**/*.js", "runtime/**/*.js", "test/**/*.js", "app/**/*.js", "app/**/*.jsx"], + files: [ + "editor/**/*.{js,ts,tsx}", + "runtime/**/*.{js,ts}", + "test/**/*.{js,ts,tsx}", + "app/**/*.{js,jsx,ts,tsx}", + "lib/**/*.{js,ts}", + ], plugins: { react: reactPlugin, "react-hooks": hooksPlugin, @@ -34,6 +41,11 @@ export default defineConfig([ "spaced-comment": ["error", "always"], }, }, + // TypeScript-specific configuration + ...tseslint.configs.recommended.map((config) => ({ + ...config, + files: ["editor/**/*.{ts,tsx}", "runtime/**/*.ts", "test/**/*.{ts,tsx}", "app/**/*.{ts,tsx}", "lib/**/*.ts"], + })), { ignores: ["**/*.recho.js", "test/output/**/*"], }, diff --git a/lib/IntervalTree.ts b/lib/IntervalTree.ts new file mode 100644 index 0000000..82f199e --- /dev/null +++ b/lib/IntervalTree.ts @@ -0,0 +1,369 @@ +/** + * Represents an interval with a low and high endpoint. + */ +export interface Interval { + low: number; + high: number; +} + +/** + * Represents an interval with associated data. + */ +export type IntervalEntry = { + interval: Interval; + data: T; +}; + +/** + * Represents a node in the interval tree. + */ +class IntervalTreeNode { + interval: Interval; + data: T; + max: number; // Maximum high value in subtree + left: IntervalTreeNode | null = null; + right: IntervalTreeNode | null = null; + height: number = 1; + + constructor(interval: Interval, data: T) { + this.interval = interval; + this.data = data; + this.max = interval.high; + } +} + +/** + * A balanced interval tree implementation using AVL tree balancing. + * Supports efficient insertion, deletion, and overlap queries. + */ +export class IntervalTree { + private root: IntervalTreeNode | null = null; + private size: number = 0; + + /** + * Creates an IntervalTree from an array using a mapping function. + * @param items - The array of items to map + * @param mapper - Function that maps each item to an interval and data value + * @returns A new IntervalTree containing all mapped intervals + */ + static from(items: S[], mapper: (item: S, index: number) => IntervalEntry | null): IntervalTree { + const tree = new IntervalTree(); + for (let i = 0, n = items.length; i < n; i++) { + const item = items[i]; + const mapped = mapper(item, i); + if (mapped) tree.insert(mapped.interval, mapped.data); + } + return tree; + } + + /** + * Returns the number of intervals in the tree. + */ + get length(): number { + return this.size; + } + + /** + * Returns true if the tree is empty. + */ + isEmpty(): boolean { + return this.root === null; + } + + /** + * Inserts an interval with associated data into the tree. + */ + insert(interval: Interval, data: T): void { + if (interval.low > interval.high) { + throw new Error("Invalid interval: low must be less than or equal to high"); + } + this.root = this.insertNode(this.root, interval, data); + this.size++; + } + + private insertNode(node: IntervalTreeNode | null, interval: Interval, data: T): IntervalTreeNode { + // Standard BST insertion + if (node === null) { + return new IntervalTreeNode(interval, data); + } + + if (interval.low < node.interval.low) { + node.left = this.insertNode(node.left, interval, data); + } else { + node.right = this.insertNode(node.right, interval, data); + } + + // Update height and max + node.height = 1 + Math.max(this.getHeight(node.left), this.getHeight(node.right)); + node.max = Math.max(node.interval.high, Math.max(this.getMax(node.left), this.getMax(node.right))); + + // Balance the tree + return this.balance(node); + } + + /** + * Deletes an interval from the tree. + * Returns true if the interval was found and deleted, false otherwise. + */ + delete(interval: Interval): boolean { + const initialSize = this.size; + this.root = this.deleteNode(this.root, interval); + return this.size < initialSize; + } + + private deleteNode(node: IntervalTreeNode | null, interval: Interval): IntervalTreeNode | null { + if (node === null) { + return null; + } + + if (interval.low < node.interval.low) { + node.left = this.deleteNode(node.left, interval); + } else if (interval.low > node.interval.low) { + node.right = this.deleteNode(node.right, interval); + } else if (interval.high === node.interval.high) { + // Found the node to delete + this.size--; + + // Node with only one child or no child + if (node.left === null) { + return node.right; + } else if (node.right === null) { + return node.left; + } + + // Node with two children: get inorder successor + const successor = this.findMin(node.right); + node.interval = successor.interval; + node.data = successor.data; + node.right = this.deleteNode(node.right, successor.interval); + } else { + // Continue searching + node.right = this.deleteNode(node.right, interval); + } + + // Update height and max + node.height = 1 + Math.max(this.getHeight(node.left), this.getHeight(node.right)); + node.max = Math.max(node.interval.high, Math.max(this.getMax(node.left), this.getMax(node.right))); + + // Balance the tree + return this.balance(node); + } + + private findMin(node: IntervalTreeNode): IntervalTreeNode { + while (node.left !== null) { + node = node.left; + } + return node; + } + + /** + * Searches for all intervals that overlap with the given interval. + */ + search(interval: Interval): Array> { + const results: Array> = []; + this.searchNode(this.root, interval, results); + return results; + } + + private searchNode(node: IntervalTreeNode | null, interval: Interval, results: Array>): void { + if (node === null) { + return; + } + + // Check if intervals overlap + if (this.doOverlap(node.interval, interval)) { + results.push({interval: node.interval, data: node.data}); + } + + // If left child exists and its max >= interval.low, search left + if (node.left !== null && node.left.max >= interval.low) { + this.searchNode(node.left, interval, results); + } + + // Search right subtree if it can contain overlapping intervals + if (node.right !== null && node.interval.low <= interval.high) { + this.searchNode(node.right, interval, results); + } + } + + /** + * Finds any one interval that overlaps with the given interval. + * Returns null if no overlapping interval is found. + */ + findAny(interval: Interval): IntervalEntry | null { + return this.findAnyNode(this.root, interval); + } + + private findAnyNode(node: IntervalTreeNode | null, interval: Interval): IntervalEntry | null { + if (node === null) { + return null; + } + + // Check if current node overlaps + if (this.doOverlap(node.interval, interval)) { + return {interval: node.interval, data: node.data}; + } + + // If left child can contain overlapping interval, search left first + if (node.left !== null && node.left.max >= interval.low) { + return this.findAnyNode(node.left, interval); + } + + // Otherwise search right + return this.findAnyNode(node.right, interval); + } + + /** + * Finds the first interval that contains the given point. + * Returns null if no interval contains the point. + */ + contains(point: number): IntervalEntry | null { + return this.containsNode(this.root, point); + } + + private containsNode(node: IntervalTreeNode | null, point: number): IntervalEntry | null { + if (node === null) { + return null; + } + + // Check if current node contains the point + if (node.interval.low <= point && point <= node.interval.high) { + return {interval: node.interval, data: node.data}; + } + + // If left child exists and its max >= point, search left first + if (node.left !== null && node.left.max >= point) { + return this.containsNode(node.left, point); + } + + // Otherwise search right + return this.containsNode(node.right, point); + } + + /** + * Returns all intervals in the tree. + */ + toArray(): Array> { + const results: Array> = []; + this.inorderTraversal(this.root, results); + return results; + } + + private inorderTraversal(node: IntervalTreeNode | null, results: Array>): void { + if (node === null) { + return; + } + this.inorderTraversal(node.left, results); + results.push({interval: node.interval, data: node.data}); + this.inorderTraversal(node.right, results); + } + + /** + * Clears all intervals from the tree. + */ + clear(): void { + this.root = null; + this.size = 0; + } + + // AVL tree balancing methods + + private getHeight(node: IntervalTreeNode | null): number { + return node === null ? 0 : node.height; + } + + private getMax(node: IntervalTreeNode | null): number { + return node === null ? -Infinity : node.max; + } + + private getBalanceFactor(node: IntervalTreeNode): number { + return this.getHeight(node.left) - this.getHeight(node.right); + } + + private balance(node: IntervalTreeNode): IntervalTreeNode { + const balanceFactor = this.getBalanceFactor(node); + + // Left heavy + if (balanceFactor > 1) { + if (this.getBalanceFactor(node.left!) < 0) { + // Left-Right case + node.left = this.rotateLeft(node.left!); + } + // Left-Left case + return this.rotateRight(node); + } + + // Right heavy + if (balanceFactor < -1) { + if (this.getBalanceFactor(node.right!) > 0) { + // Right-Left case + node.right = this.rotateRight(node.right!); + } + // Right-Right case + return this.rotateLeft(node); + } + + return node; + } + + private rotateLeft(node: IntervalTreeNode): IntervalTreeNode { + const newRoot = node.right!; + node.right = newRoot.left; + newRoot.left = node; + + // Update heights + node.height = 1 + Math.max(this.getHeight(node.left), this.getHeight(node.right)); + newRoot.height = 1 + Math.max(this.getHeight(newRoot.left), this.getHeight(newRoot.right)); + + // Update max values + node.max = Math.max(node.interval.high, Math.max(this.getMax(node.left), this.getMax(node.right))); + newRoot.max = Math.max(newRoot.interval.high, Math.max(this.getMax(newRoot.left), this.getMax(newRoot.right))); + + return newRoot; + } + + private rotateRight(node: IntervalTreeNode): IntervalTreeNode { + const newRoot = node.left!; + node.left = newRoot.right; + newRoot.right = node; + + // Update heights + node.height = 1 + Math.max(this.getHeight(node.left), this.getHeight(node.right)); + newRoot.height = 1 + Math.max(this.getHeight(newRoot.left), this.getHeight(newRoot.right)); + + // Update max values + node.max = Math.max(node.interval.high, Math.max(this.getMax(node.left), this.getMax(node.right))); + newRoot.max = Math.max(newRoot.interval.high, Math.max(this.getMax(newRoot.left), this.getMax(newRoot.right))); + + return newRoot; + } + + private doOverlap(a: Interval, b: Interval): boolean { + return a.low <= b.high && b.low <= a.high; + } + + /** + * Returns true if the tree is balanced (for testing purposes). + */ + isBalanced(): boolean { + return this.checkBalance(this.root) !== -1; + } + + private checkBalance(node: IntervalTreeNode | null): number { + if (node === null) { + return 0; + } + + const leftHeight = this.checkBalance(node.left); + if (leftHeight === -1) return -1; + + const rightHeight = this.checkBalance(node.right); + if (rightHeight === -1) return -1; + + if (Math.abs(leftHeight - rightHeight) > 1) { + return -1; + } + + return Math.max(leftHeight, rightHeight) + 1; + } +} diff --git a/lib/blocks.ts b/lib/blocks.ts new file mode 100644 index 0000000..f51947c --- /dev/null +++ b/lib/blocks.ts @@ -0,0 +1,187 @@ +/** + * Find the block where the given position is adjacent to. There are a few + * possible cases. + * + * - If `pos` is inside a block, return the block directly. + * - Otherwise, `pos` is not inside any block. We return the block before + * and the block after `pos`. If any one of them does not exist, return + * `null` instead. + * @param blocks The array of blocks. The right boundary `to` is exclusive. + * @param pos The position of the selection + * @returns If `pos` is inside a block, return the block index. If `pos` is + * between two blocks, return the indices of the two blocks. Note that if + * `pos` is before the first block, return `[-1, 0]`. If `pos` is after + * the last block, return `[blocks.length - 1, blocks.length]`. Lastly, if + * `blocks` is empty, return `null`. + */ +export function findAdjacentBlocks( + blocks: {from: number; to: number}[], + pos: number, +): number | [number, number] | null { + try { + // console.groupCollapsed(`findAdjacentBlocks(${pos})`); + + // We maintain a binary search range [left, right). Note that the right + // boundary is exclusive, i.e., the range is empty when `left === right`. + let left = 0; + let right = blocks.length; + + // When the range is non-empty. + while (left < right) { + const middle = (left + right) >>> 1; + const pivot = blocks[middle]!; + + if (pos < pivot.from) { + // We should move to the left sub-range [left, middle). + if (left === middle) { + return [middle - 1, middle]; + } else { + right = middle; + } + } else if (pivot.to <= pos) { + // We should move to the right sub-range [middle, right). + if (left === middle) { + return [middle, middle + 1]; + } else { + left = middle; + } + } else { + // Good news: `pos` is inside `pivot`. + return middle; + } + } + + return null; + } finally { + // console.groupEnd(); + } +} + +export type BlockRange = [from: number | null, to: number | null]; + +export function blockRangeLength(blockCount: number, [from, to]: BlockRange): number { + if (from === null) { + if (to === null) { + return blockCount; + } else { + return to; + } + } else { + if (to === null) { + return blockCount - from; + } else { + return to - from; + } + } +} + +/** + * If there is only one block in the range, return its index. Otherwise, return + * `null`. + * + * @param blockCount the number of blocks + * @param param1 the range of blocks + * @returns the range of the block + */ +export function getOnlyOneBlock(blockCount: number, [from, to]: BlockRange): number | null { + if (from === null) { + if (to === null) { + return null; + } else { + return to === 1 ? 0 : null; + } + } else { + if (to === null) { + return blockCount - from === 1 ? from : null; + } else { + return to - from === 1 ? from : null; + } + } +} + +/** + * Finds the range of affected blocks in an array of disjoint and sorted blocks. + * The ranges of blocks whose indices within the returned range should be + * overlapping with range [from, to). The indices of blocks whose ranges are + * overlapping with range [from, to) should be returned. + * + * Note that the returned range does not represent the relationship between the + * edited area and the blocks. An edit may affect nearby blocks even if those + * blocks are not directly modified. For example, adding a plus `+` between two + * function calls would merge the two blocks into a new expression. + * + * @param blocks The array of blocks. Blocks are disjoint and sorted. + * @param from The starting index of the edited range. + * @param to The ending index (exclusive) of the edited range. + * @returns The range of affected blocks. The + */ +export function findAffectedBlockRange( + blocks: {from: number; to: number}[], + from: number, + to: number, +): [from: number | null, to: number | null] { + if (from > to) { + throw new RangeError("`from` must be less than or equal to `to`"); + } + + if (from < 0) { + throw new RangeError("`from` must be greater than or equal to 0"); + } + + const fromResult = findAdjacentBlocks(blocks, from); + + // In insertion transactions, `from` and `to` are identical because they do + // not delete any characters from the document. + if (from === to) { + if (typeof fromResult === "number") { + // This means that the insertion point is in a block, thus only the block + // is affected. We return [index, index + 1). + return [fromResult, fromResult + 1]; + } else if (fromResult === null) { + // There is no block at all. We should re-parse the entire document. + return [null, null]; + } else { + // The insertion point is between blocks. We should include two blocks. + const [blockIndexBefore, blockIndexAfter] = fromResult; + return [ + // `null` means we should the caller should use the beginning of the document. + blockIndexBefore === -1 ? null : blockIndexBefore, + blockIndexAfter === blocks.length ? null : blockIndexAfter + 1, + ]; + } + } + + // Otherwise, the transaction deletes characters from the document. + // Note that we use `to - 1` because we want to locate the last character. + // Using `to` is wrong because it would locate the first character of the next block. + const toResult = findAdjacentBlocks(blocks, to - 1); + + // This means that we are deleting from non-block region. + if (fromResult === null || toResult === null) { + // If there is no block at all, then both of them should be `null`. + if (fromResult !== toResult) { + throw new RangeError("`from` and `to` should be located to nowhere at the same time", { + cause: {ranges: blocks.map((block) => ({from: block.from, to: block.to})), from, to}, + }); + } + return [null, null]; + } + + if (typeof fromResult === "number") { + if (typeof toResult === "number") { + return [fromResult, toResult === blocks.length ? null : toResult + 1]; + } else { + const blockIndexAfterTo = toResult[1]; + return [fromResult, blockIndexAfterTo === blocks.length ? null : blockIndexAfterTo + 1]; + } + } else { + const blockIndexBeforeFrom = fromResult[0]; + const rangeFrom = blockIndexBeforeFrom === -1 ? null : blockIndexBeforeFrom; + if (typeof toResult === "number") { + return [rangeFrom, toResult === blocks.length ? null : toResult + 1]; + } else { + const blockIndexAfterTo = toResult[1]; + return [rangeFrom, blockIndexAfterTo === blocks.length ? null : blockIndexAfterTo + 1]; + } + } +} diff --git a/lib/blocks/detect.ts b/lib/blocks/detect.ts new file mode 100644 index 0000000..be9f6f0 --- /dev/null +++ b/lib/blocks/detect.ts @@ -0,0 +1,130 @@ +import {type Text} from "@codemirror/state"; +import {OUTPUT_MARK, ERROR_MARK} from "../../runtime/constant.js"; +import {syntaxTree} from "@codemirror/language"; +import {BlockMetadata, type Range} from "../../editor/blocks/BlockMetadata.ts"; + +const OUTPUT_MARK_CODE_POINT = OUTPUT_MARK.codePointAt(0); +const ERROR_MARK_CODE_POINT = ERROR_MARK.codePointAt(0); + +// Since CodeMirror does not export `SyntaxNode`, we have to get it in this way. +type Tree = ReturnType; +type SyntaxNode = Tree["topNode"]; + +function extendOutputForward(doc: Text, node: SyntaxNode): Range | null { + let outputRange: Range | null = null; + let currentNode = node.node.prevSibling; + + while (currentNode?.name === "LineComment") { + const line = doc.lineAt(currentNode.from); + if (line.from === currentNode.from && line.to === currentNode.to) { + const codePoint = line.text.codePointAt(2); + if (codePoint === OUTPUT_MARK_CODE_POINT || codePoint === ERROR_MARK_CODE_POINT) { + outputRange = outputRange === null ? {from: line.from, to: line.to} : {from: line.from, to: outputRange.to}; + } + } + currentNode = currentNode.prevSibling; + } + + return outputRange; +} + +/** + * Detect blocks in a given range by traversing the syntax tree. + * Similar to how runtime/index.js uses acorn to parse blocks, but adapted for CodeMirror. + */ +export function detectBlocksWithinRange(tree: Tree, doc: Text, from: number, to: number): BlockMetadata[] { + const blocks: BlockMetadata[] = []; + + // Collect all top-level statements and their preceding output/error comment lines + const statementRanges: (Range & {name: string})[] = []; + const outputRanges = new Map(); // Map statement position to output range + + tree.iterate({ + from, + to, + enter: (node) => { + // Detect top-level statements (direct children of Script) + if (node.node.parent?.name === "Script") { + // Check if this is a statement (not a comment) + if ( + node.name.includes("Statement") || + node.name.includes("Declaration") || + node.name === "ExportDeclaration" || + node.name === "ImportDeclaration" || + node.name === "Block" + ) { + statementRanges.push({from: node.from, to: node.to, name: node.name}); + + const outputRange = extendOutputForward(doc, node.node); + if (outputRange !== null) { + outputRanges.set(node.from, outputRange); + } + } + // Detect output/error comment lines (top-level line comments) + else if (node.name === "LineComment") { + // Get the line containing the comment. + const line = doc.lineAt(node.from); + + // Check if the line comment covers the entire line + if (line.from === node.from && line.to === node.to) { + const codePoint = line.text.codePointAt(2); + if (codePoint === OUTPUT_MARK_CODE_POINT || codePoint === ERROR_MARK_CODE_POINT) { + // Find consecutive output/error lines + let outputStart = line.from; + let outputEnd = line.to; + + // Look backwards for more output/error lines + let currentLineNum = line.number - 1; + while (currentLineNum >= 1) { + const prevLine = doc.line(currentLineNum); + const prevCodePoint = prevLine.text.codePointAt(2); + if ( + prevLine.text.startsWith("//") && + (prevCodePoint === OUTPUT_MARK_CODE_POINT || prevCodePoint === ERROR_MARK_CODE_POINT) + ) { + outputStart = prevLine.from; + currentLineNum--; + } else { + break; + } + } + + // Look forwards for more output/error lines + currentLineNum = line.number + 1; + const totalLines = doc.lines; + while (currentLineNum <= totalLines) { + const nextLine = doc.line(currentLineNum); + const nextCodePoint = nextLine.text.codePointAt(2); + if ( + nextLine.text.startsWith("//") && + (nextCodePoint === OUTPUT_MARK_CODE_POINT || nextCodePoint === ERROR_MARK_CODE_POINT) + ) { + outputEnd = nextLine.to; + currentLineNum++; + } else { + break; + } + } + + // Find the next statement after these output lines + // The output belongs to the statement immediately following it + const nextStatementLine = currentLineNum; + if (nextStatementLine <= totalLines) { + const nextStmtLine = doc.line(nextStatementLine); + // Store this output range to be associated with the next statement + outputRanges.set(nextStmtLine.from, {from: outputStart, to: outputEnd}); + } + } + } + } + } + }, + }); + + // Build block metadata from statements + for (const range of statementRanges) { + blocks.push(new BlockMetadata(range.name, outputRanges.get(range.from) ?? null, range)); + } + + return blocks; +} diff --git a/lib/containers/heap.ts b/lib/containers/heap.ts new file mode 100644 index 0000000..dcc34d8 --- /dev/null +++ b/lib/containers/heap.ts @@ -0,0 +1,144 @@ +export class MaxHeap { + private heap: T[] = []; + private weights: number[] = []; + private indexMap: Map = new Map(); + private keyFn: (value: T) => number; + private compareFn: (a: number, b: number) => number; + + constructor(keyFn: (value: T) => number, compareFn: (a: number, b: number) => number = (a, b) => a - b) { + this.keyFn = keyFn; + this.compareFn = compareFn; + } + + private parent(i: number): number { + return Math.floor((i - 1) / 2); + } + + private leftChild(i: number): number { + return 2 * i + 1; + } + + private rightChild(i: number): number { + return 2 * i + 2; + } + + private swap(i: number, j: number): void { + [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; + [this.weights[i], this.weights[j]] = [this.weights[j], this.weights[i]]; + this.indexMap.set(this.heap[i]!, i); + this.indexMap.set(this.heap[j]!, j); + } + + private percolateUp(i: number): void { + while (i > 0) { + const p = this.parent(i); + if (this.compareFn(this.weights[i]!, this.weights[p]!) > 0) { + this.swap(i, p); + i = p; + } else { + break; + } + } + } + + private percolateDown(i: number): void { + const size = this.heap.length; + while (true) { + let largest = i; + const left = this.leftChild(i); + const right = this.rightChild(i); + + if (left < size && this.compareFn(this.weights[left], this.weights[largest]) > 0) { + largest = left; + } + if (right < size && this.compareFn(this.weights[right], this.weights[largest]) > 0) { + largest = right; + } + + if (largest !== i) { + this.swap(i, largest); + i = largest; + } else { + break; + } + } + } + + insert(value: T): void { + const weight = this.keyFn(value); + const existingIndex = this.indexMap.get(value); + + if (existingIndex !== undefined) { + const oldWeight = this.weights[existingIndex]; + if (weight !== oldWeight) { + this.weights[existingIndex] = weight; + const cmp = this.compareFn(weight, oldWeight); + if (cmp > 0) { + this.percolateUp(existingIndex); + } else if (cmp < 0) { + this.percolateDown(existingIndex); + } + } + } else { + const index = this.heap.length; + this.heap.push(value); + this.weights.push(weight); + this.indexMap.set(value, index); + this.percolateUp(index); + } + } + + extractMax(): T | undefined { + if (this.heap.length === 0) { + return undefined; + } + + const max = this.heap[0]; + this.indexMap.delete(max); + + if (this.heap.length === 1) { + this.heap.pop(); + this.weights.pop(); + return max; + } + + this.heap[0] = this.heap.pop()!; + this.weights[0] = this.weights.pop()!; + this.indexMap.set(this.heap[0], 0); + this.percolateDown(0); + + return max; + } + + get peek(): T | undefined { + return this.heap[0]; + } + + peekWeight(): number | undefined { + return this.weights[0]; + } + + has(value: T): boolean { + return this.indexMap.has(value); + } + + get size(): number { + return this.heap.length; + } + + get isEmpty(): boolean { + return this.heap.length === 0; + } + + nonEmpty(): this is NonEmptyHeap { + return this.heap.length > 0; + } + + clear(): void { + this.heap = []; + this.weights = []; + this.indexMap.clear(); + } +} + +type NonEmptyHeap = Omit, "peek"> & {readonly peek: T}; diff --git a/lib/containers/range-tree.ts b/lib/containers/range-tree.ts new file mode 100644 index 0000000..3e0f632 --- /dev/null +++ b/lib/containers/range-tree.ts @@ -0,0 +1,228 @@ +/** + * Represents a half-open range [from, to) where from is inclusive and to is exclusive. + */ +export interface Range { + from: number; + to: number; +} + +/** + * Node in the AVL-based range tree. + */ +class RangeNode { + range: Range; + max: number; // Maximum to value in this subtree + height: number; + left: RangeNode | null = null; + right: RangeNode | null = null; + + constructor(range: Range) { + this.range = range; + this.max = range.to; + this.height = 1; + } +} + +/** + * A balanced range tree that supports efficient interval operations. + * Ranges are left-inclusive and right-exclusive: [from, to). + */ +export class RangeTree { + private root: RangeNode | null = null; + + /** + * Inserts a new range into the tree. + */ + insert(from: number, to: number): void { + if (from >= to) { + throw new Error("Invalid range: from must be less than to"); + } + this.root = this.insertNode(this.root, {from, to}); + } + + /** + * Checks if any range in the tree overlaps with the given range. + */ + hasOverlap(from: number, to: number): boolean { + if (from >= to) { + throw new Error("Invalid range: from must be less than to"); + } + return this.searchOverlap(this.root, {from, to}) !== null; + } + + /** + * Checks if any range in the tree contains the given position. + */ + contains(position: number): boolean { + return this.searchContains(this.root, position) !== null; + } + + /** + * Retrieves all ranges that overlap with the given range. + */ + findAllOverlapping(from: number, to: number): Range[] { + if (from >= to) { + throw new Error("Invalid range: from must be less than to"); + } + const result: Range[] = []; + this.collectOverlapping(this.root, {from, to}, result); + return result; + } + + private insertNode(node: RangeNode | null, range: Range): RangeNode { + // Standard BST insertion + if (node === null) { + return new RangeNode(range); + } + + if (range.from < node.range.from) { + node.left = this.insertNode(node.left, range); + } else { + node.right = this.insertNode(node.right, range); + } + + // Update height and max + this.updateNode(node); + + // Balance the node + return this.balance(node); + } + + private searchOverlap(node: RangeNode | null, range: Range): RangeNode | null { + if (node === null) { + return null; + } + + // Check if current node's range overlaps with the search range + if (this.overlaps(node.range, range)) { + return node; + } + + // If left child exists and its max is greater than range.from, + // there might be an overlap in the left subtree + if (node.left !== null && node.left.max > range.from) { + const leftResult = this.searchOverlap(node.left, range); + if (leftResult !== null) { + return leftResult; + } + } + + // Search in the right subtree + return this.searchOverlap(node.right, range); + } + + private searchContains(node: RangeNode | null, position: number): RangeNode | null { + if (node === null) { + return null; + } + + // Check if current node's range contains the position + if (node.range.from <= position && position < node.range.to) { + return node; + } + + // If left child exists and its max is greater than position, + // there might be a containing range in the left subtree + if (node.left !== null && node.left.max > position) { + const leftResult = this.searchContains(node.left, position); + if (leftResult !== null) { + return leftResult; + } + } + + // Search in the right subtree + return this.searchContains(node.right, position); + } + + private collectOverlapping(node: RangeNode | null, range: Range, result: Range[]): void { + if (node === null) { + return; + } + + // Check if current node's range overlaps + if (this.overlaps(node.range, range)) { + result.push({...node.range}); + } + + // If left child exists and its max is greater than range.from, + // there might be overlaps in the left subtree + if (node.left !== null && node.left.max > range.from) { + this.collectOverlapping(node.left, range, result); + } + + // Always check right subtree if the range might extend there + if (node.right !== null && node.range.from < range.to) { + this.collectOverlapping(node.right, range, result); + } + } + + private overlaps(a: Range, b: Range): boolean { + // Two half-open ranges [a.from, a.to) and [b.from, b.to) overlap if: + // a.from < b.to AND b.from < a.to + return a.from < b.to && b.from < a.to; + } + + private updateNode(node: RangeNode): void { + const leftHeight = node.left?.height ?? 0; + const rightHeight = node.right?.height ?? 0; + node.height = Math.max(leftHeight, rightHeight) + 1; + + const leftMax = node.left?.max ?? Number.NEGATIVE_INFINITY; + const rightMax = node.right?.max ?? Number.NEGATIVE_INFINITY; + node.max = Math.max(node.range.to, leftMax, rightMax); + } + + private getBalance(node: RangeNode): number { + const leftHeight = node.left?.height ?? 0; + const rightHeight = node.right?.height ?? 0; + return leftHeight - rightHeight; + } + + private balance(node: RangeNode): RangeNode { + const balance = this.getBalance(node); + + // Left heavy + if (balance > 1) { + if (node.left && this.getBalance(node.left) < 0) { + // Left-Right case + node.left = this.rotateLeft(node.left); + } + // Left-Left case + return this.rotateRight(node); + } + + // Right heavy + if (balance < -1) { + if (node.right && this.getBalance(node.right) > 0) { + // Right-Left case + node.right = this.rotateRight(node.right); + } + // Right-Right case + return this.rotateLeft(node); + } + + return node; + } + + private rotateLeft(node: RangeNode): RangeNode { + const newRoot = node.right!; + node.right = newRoot.left; + newRoot.left = node; + + this.updateNode(node); + this.updateNode(newRoot); + + return newRoot; + } + + private rotateRight(node: RangeNode): RangeNode { + const newRoot = node.left!; + node.left = newRoot.right; + newRoot.right = node; + + this.updateNode(node); + this.updateNode(newRoot); + + return newRoot; + } +} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 2d4aa6f..53e25f9 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,14 @@ "test:lint": "eslint" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/postcss": "^4.1.14", + "@types/node": "24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.49.0", + "@typescript-eslint/parser": "^8.49.0", "@vercel/analytics": "^1.6.1", + "@vitejs/plugin-react": "^4.3.1", "clsx": "^2.1.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -47,9 +53,13 @@ "lucide-react": "^0.542.0", "postcss": "^8.5.6", "prettier": "^3.7.4", + "react-inspector": "^9.0.0", + "react-resizable-panels": "^3.0.6", "react-tooltip": "^5.30.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", "vite": "^7.2.7", "vitest": "^3.2.4" }, @@ -66,6 +76,7 @@ "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/spline-sans-mono": "^5.2.8", "@lezer/highlight": "^1.2.3", + "@lezer/javascript": "^1.5.4", "@observablehq/notebook-kit": "^1.5.0", "@observablehq/runtime": "^6.0.0", "@uiw/codemirror-theme-github": "^4.25.3", @@ -78,6 +89,7 @@ "d3-require": "^1.3.0", "eslint-linter-browserify": "^9.39.1", "friendly-words": "^1.3.1", + "jotai": "^2.10.3", "next": "^15.5.7", "nstr": "^0.1.3", "object-inspect": "^1.13.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10c089f..654e951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,12 @@ importers: '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 + '@lezer/javascript': + specifier: ^1.5.4 + version: 1.5.4 '@observablehq/notebook-kit': specifier: ^1.5.0 - version: 1.5.0(@types/markdown-it@14.1.2)(jiti@2.6.1)(lightningcss@1.30.2) + version: 1.5.0(@types/markdown-it@14.1.2)(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) '@observablehq/runtime': specifier: ^6.0.0 version: 6.0.0 @@ -80,9 +83,12 @@ importers: friendly-words: specifier: ^1.3.1 version: 1.3.1 + jotai: + specifier: ^2.10.3 + version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.6)(react@19.2.1) next: specifier: ^15.5.7 - version: 15.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 15.5.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) nstr: specifier: ^0.1.3 version: 0.1.3 @@ -109,11 +115,29 @@ importers: version: 6.9.0 devDependencies: '@tailwindcss/postcss': - specifier: ^4.1.17 + specifier: ^4.1.14 version: 4.1.17 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@types/react': + specifier: 19.2.6 + version: 19.2.6 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.6) + '@typescript-eslint/eslint-plugin': + specifier: ^8.49.0 + version: 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.49.0 + version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 1.6.1(next@15.5.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -141,6 +165,12 @@ importers: prettier: specifier: ^3.7.4 version: 3.7.4 + react-inspector: + specifier: ^9.0.0 + version: 9.0.0(react@19.2.1) + react-resizable-panels: + specifier: ^3.0.6 + version: 3.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react-tooltip: specifier: ^5.30.0 version: 5.30.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -150,12 +180,18 @@ importers: tailwindcss: specifier: ^4.1.17 version: 4.1.17 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.49.0 + version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.2.7 - version: 7.2.7(jiti@2.6.1)(lightningcss@1.30.2) + version: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@edge-runtime/vm@3.2.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) + version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2) packages: @@ -166,10 +202,93 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@codemirror/autocomplete@6.20.0': resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} @@ -834,6 +953,9 @@ packages: '@observablehq/runtime@6.0.0': resolution: {integrity: sha512-t3UXP69O0JK20HY/neF4/DDDSDorwo92As806Y1pNTgTmj1NtoPyVpesYzfH31gTFOFrXC2cArV+wLpebMk9eA==} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/rollup-android-arm-eabi@4.53.3': resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] @@ -1064,6 +1186,18 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1091,9 +1225,79 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.6': + resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.49.0': + resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.49.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.49.0': + resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.49.0': + resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.49.0': + resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.49.0': + resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.49.0': + resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.49.0': + resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.49.0': + resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.49.0': + resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.49.0': + resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uiw/codemirror-theme-github@4.25.3': resolution: {integrity: sha512-KdmcO9VicsBgsDErNrNBqwMuTbJRIpeMl9oIjmrNx2iEfIDSOMBIKlX+BkgwTAU+VmhqYY/68/kmF1K8z2FxrQ==} @@ -1133,6 +1337,12 @@ packages: vue-router: optional: true + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1295,6 +1505,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.8.28: + resolution: {integrity: sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1312,10 +1526,18 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1347,6 +1569,9 @@ packages: resolution: {integrity: sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==} engines: {node: '>=12.20'} + caniuse-lite@1.0.30001754: + resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} @@ -1461,6 +1686,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1490,6 +1718,9 @@ packages: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + currently-unhandled@0.4.1: resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==} engines: {node: '>=0.10.0'} @@ -1610,6 +1841,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.251: + resolution: {integrity: sha512-lmyEOp4G0XT3qrYswNB4np1kx90k6QCXpnSHYv2xEsUuEu8JCobpDVYO6vMseirQyyCC6GCIGGxd5szMBa0tRA==} + emittery@1.2.0: resolution: {integrity: sha512-KxdRyyFcS85pH3dnU8Y5yFUm2YJdaHwcBZWrfG8o89ZY9a13/f9itbN+YG3ELbBo9Pg5zvIozstmuV8bX13q6g==} engines: {node: '>=14.16'} @@ -1881,6 +2115,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2003,6 +2241,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2180,6 +2422,24 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jotai@2.15.1: + resolution: {integrity: sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-string-escape@1.0.1: resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} engines: {node: '>= 0.8'} @@ -2207,6 +2467,11 @@ packages: canvas: optional: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2219,6 +2484,11 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2337,6 +2607,9 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.542.0: resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==} peerDependencies: @@ -2435,6 +2708,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2474,6 +2751,9 @@ packages: sass: optional: true + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nofilter@3.1.0: resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} engines: {node: '>=12.19'} @@ -2701,9 +2981,24 @@ packages: peerDependencies: react: ^19.2.1 + react-inspector@9.0.0: + resolution: {integrity: sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-resizable-panels@3.0.6: + resolution: {integrity: sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-tooltip@5.30.0: resolution: {integrity: sha512-Yn8PfbgQ/wmqnL7oBpz1QiDaLKrzZMdSUUdk7nVeGTwzbxCAJiJzR4VSYW+eIO42F1INt57sPUmpgKv0KwJKtg==} peerDependencies: @@ -3078,6 +3373,12 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3109,6 +3410,13 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typescript-eslint@8.49.0: + resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3121,6 +3429,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} @@ -3144,6 +3455,12 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3326,6 +3643,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3357,8 +3677,120 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.11.3 @@ -4049,7 +4481,7 @@ snapshots: dependencies: isoformat: 0.2.1 - '@observablehq/notebook-kit@1.5.0(@types/markdown-it@14.1.2)(jiti@2.6.1)(lightningcss@1.30.2)': + '@observablehq/notebook-kit@1.5.0(@types/markdown-it@14.1.2)(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)': dependencies: '@fontsource/inter': 5.2.8 '@fontsource/source-serif-4': 5.2.9 @@ -4071,7 +4503,7 @@ snapshots: markdown-it: 14.1.0 markdown-it-anchor: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.0) typescript: 5.9.3 - vite: 7.2.7(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - '@types/markdown-it' - '@types/node' @@ -4097,6 +4529,8 @@ snapshots: '@observablehq/runtime@6.0.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/rollup-android-arm-eabi@4.53.3': optional: true @@ -4278,6 +4712,27 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4306,8 +4761,111 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.6)': + dependencies: + '@types/react': 19.2.6 + + '@types/react@19.2.6': + dependencies: + csstype: 3.2.3 + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 + eslint: 9.39.1(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.49.0': + dependencies: + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 + + '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.49.0': {} + + '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.49.0': + dependencies: + '@typescript-eslint/types': 8.49.0 + eslint-visitor-keys: 4.2.1 + '@uiw/codemirror-theme-github@4.25.3(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.39.2)': dependencies: '@uiw/codemirror-themes': 4.25.3(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.39.2) @@ -4324,11 +4882,23 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/analytics@1.6.1(next@15.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@vercel/analytics@1.6.1(next@15.5.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': optionalDependencies: - next: 15.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 + '@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -4337,13 +4907,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.2.7(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitest/mocker@3.2.4(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.7(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -4555,6 +5125,8 @@ snapshots: balanced-match@1.0.2: {} + baseline-browser-mapping@2.8.28: {} + binary-extensions@2.3.0: {} blueimp-md5@2.19.0: {} @@ -4583,10 +5155,22 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.28 + caniuse-lite: 1.0.30001754 + electron-to-chromium: 1.5.251 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + buffer-from@1.1.2: {} bytes@3.1.2: {} @@ -4614,6 +5198,8 @@ snapshots: callsites@4.2.0: {} + caniuse-lite@1.0.30001754: {} + caniuse-lite@1.0.30001760: {} cbor@8.1.0: @@ -4750,6 +5336,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} cookie-signature@1.0.7: {} @@ -4779,6 +5367,8 @@ snapshots: '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + currently-unhandled@0.4.1: dependencies: array-find-index: 1.0.2 @@ -4892,6 +5482,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.251: {} + emittery@1.2.0: {} emoji-regex@8.0.0: {} @@ -5307,6 +5899,8 @@ snapshots: generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -5457,6 +6051,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5623,6 +6219,13 @@ snapshots: jiti@2.6.1: {} + jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.6)(react@19.2.1): + optionalDependencies: + '@babel/core': 7.28.5 + '@babel/template': 7.27.2 + '@types/react': 19.2.6 + react: 19.2.1 + js-string-escape@1.0.1: {} js-tokens@4.0.0: {} @@ -5665,6 +6268,8 @@ snapshots: - supports-color - utf-8-validate + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -5673,6 +6278,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -5768,6 +6375,10 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lucide-react@0.542.0(react@19.2.1): dependencies: react: 19.2.1 @@ -5867,6 +6478,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + ms@2.0.0: {} ms@2.1.3: {} @@ -5877,7 +6492,7 @@ snapshots: negotiator@0.6.3: {} - next@15.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@15.5.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@next/env': 15.5.7 '@swc/helpers': 0.5.15 @@ -5885,7 +6500,7 @@ snapshots: postcss: 8.4.31 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - styled-jsx: 5.1.6(react@19.2.1) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) optionalDependencies: '@next/swc-darwin-arm64': 15.5.7 '@next/swc-darwin-x64': 15.5.7 @@ -5900,6 +6515,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-releases@2.0.27: {} + nofilter@3.1.0: {} normalize-path@3.0.0: {} @@ -6114,8 +6731,19 @@ snapshots: react: 19.2.1 scheduler: 0.27.0 + react-inspector@9.0.0(react@19.2.1): + dependencies: + react: 19.2.1 + react-is@16.13.1: {} + react-refresh@0.17.0: {} + + react-resizable-panels@3.0.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-tooltip@5.30.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@floating-ui/dom': 1.7.4 @@ -6525,10 +7153,12 @@ snapshots: style-mod@4.1.3: {} - styled-jsx@5.1.6(react@19.2.1): + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.1): dependencies: client-only: 0.0.1 react: 19.2.1 + optionalDependencies: + '@babel/core': 7.28.5 supertap@3.0.1: dependencies: @@ -6600,6 +7230,10 @@ snapshots: trim-lines@3.0.1: {} + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@2.8.1: {} type-check@0.4.0: @@ -6646,6 +7280,17 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} uc.micro@2.1.0: {} @@ -6657,6 +7302,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@7.16.0: {} + undici@7.16.0: {} unist-util-is@6.0.1: @@ -6684,6 +7331,12 @@ snapshots: unpipe@1.0.0: {} + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6704,13 +7357,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(jiti@2.6.1)(lightningcss@1.30.2): + vite-node@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.2.7(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - '@types/node' - jiti @@ -6725,7 +7378,7 @@ snapshots: - tsx - yaml - vite@7.2.7(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -6734,15 +7387,16 @@ snapshots: rollup: 4.53.3 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 - vitest@3.2.4(@edge-runtime/vm@3.2.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2): + vitest@3.2.4(@edge-runtime/vm@3.2.0)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.7(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/mocker': 3.2.4(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -6760,11 +7414,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.2.7(jiti@2.6.1)(lightningcss@1.30.2) - vite-node: 3.2.4(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + vite-node: 3.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 3.2.0 + '@types/node': 24.10.1 jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -6872,6 +7527,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/runtime/controls/button.js b/runtime/controls/button.js index 6ae595b..e901262 100644 --- a/runtime/controls/button.js +++ b/runtime/controls/button.js @@ -25,9 +25,9 @@ export class ButtonRegistry { */ register(id, callback) { // Check if this ID was already registered in the current execution - if (this.currentExecutionIds.has(id)) { - return false; // Duplicate in current execution - } + // if (this.currentExecutionIds.has(id)) { + // return false; // Duplicate in current execution + // } this.callbackMap.set(id, callback); this.currentExecutionIds.add(id); diff --git a/runtime/index.js b/runtime/index.js index 0d540a6..8555746 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -6,6 +6,9 @@ import {dispatch as d3Dispatch} from "d3-dispatch"; import * as stdlib from "./stdlib/index.js"; import {Inspector} from "./stdlib/inspect.js"; import {OUTPUT_MARK, ERROR_MARK} from "./constant.js"; +import {BlockMetadata} from "../editor/blocks/BlockMetadata.ts"; +import {blockMetadataEffect} from "../editor/blockMetadata.ts"; +import {IntervalTree} from "../lib/IntervalTree.ts"; import {transpileRechoJavaScript} from "./transpile.js"; import {table, getBorderCharacters} from "table"; import {ButtonRegistry, makeButton} from "./controls/button.js"; @@ -133,12 +136,24 @@ export function createRuntime(initialCode) { const refresh = debounce((code) => { const changes = removeChanges(code); + // Construct an interval tree containing the ranges to be deleted. + const removedIntervals = IntervalTree.from(changes, ({from, to}, index) => + from === to ? null : {interval: {low: from, high: to - 1}, data: index}, + ); + // Process and format output for all execution nodes const nodes = Array.from(nodesByKey.values()).flat(Infinity); + const blocks = []; for (const node of nodes) { const start = node.start; const {values} = node.state; - if (!values.length) continue; + const sourceRange = {from: node.start, to: node.end}; + + if (!values.length) { + // Create a block even if there are no values. + blocks.push(new BlockMetadata(node.type, null, sourceRange, node.state.attributes)); + continue; + } // Group values by key. Each group is a row if using table, otherwise a column. const groupValues = groups(values, (v) => v.options?.key); @@ -186,10 +201,35 @@ export function createRuntime(initialCode) { columnDefault: {alignment: "right"}, }); const prefixed = addPrefix(formatted, error ? ERROR_PREFIX : OUTPUT_PREFIX); - changes.push({from: start, insert: prefixed + "\n"}); + + // The range of line numbers of output lines. + let outputRange = null; + + // Search for existing changes and update the inserted text if found. + const entry = removedIntervals.contains(start - 1); + + // Entry not found. This is a new output. + if (entry === null) { + changes.push({from: start, insert: prefixed + "\n"}); + outputRange = {from: start, to: start}; + } else { + const change = changes[entry.data]; + change.insert = prefixed + "\n"; + outputRange = {from: change.from, to: change.to}; + } + + // Add this block to the block metadata array. + const block = new BlockMetadata(node.type, outputRange, {from: node.start, to: node.end}, node.state.attributes); + block.error = error; + blocks.push(block); + + blocks.sort((a, b) => a.from - b.from); } - dispatch(changes); + // Attach block positions and attributes as effects to the transaction. + const effects = [blockMetadataEffect.of(blocks)]; + + dispatch(changes, effects); }, 0); function setCode(newCode) { @@ -200,8 +240,8 @@ export function createRuntime(initialCode) { isRunning = value; } - function dispatch(changes) { - dispatcher.call("changes", null, changes); + function dispatch(changes, effects = []) { + dispatcher.call("changes", null, {changes, effects}); } function onChanges(callback) { @@ -233,6 +273,9 @@ export function createRuntime(initialCode) { function split(code) { try { + // The `parse` call here is actually unnecessary. Parsing the entire code + // is quite expensive. If we can perform the splitting operation through + // the editor's syntax tree, we can save the parsing here. return parse(code, {ecmaVersion: "latest", sourceType: "module"}).body; } catch (error) { console.error(error); @@ -258,30 +301,45 @@ export function createRuntime(initialCode) { } } + /** + * Get the changes that remove the output lines from the code. + * @param {string} code The code to remove changes from. + * @returns {{from: number, to: number, insert: ""}[]} An array of changes. + */ function removeChanges(code) { - const changes = []; - - const oldOutputs = code - .split("\n") - .map((l, i) => [l, i]) - .filter(([l]) => l.startsWith(OUTPUT_PREFIX) || l.startsWith(ERROR_PREFIX)) - .map(([_, i]) => i); - - const lineOf = (i) => { - const lines = code.split("\n"); - const line = lines[i]; - const from = lines.slice(0, i).join("\n").length; - const to = from + line.length; - return {from, to}; - }; + function matchAt(index) { + return code.startsWith(OUTPUT_PREFIX, index) || code.startsWith(ERROR_PREFIX, index); + } - for (const i of oldOutputs) { - const line = lineOf(i); - const from = line.from; - const to = line.to + 1 > code.length ? line.to : line.to + 1; - changes.push({from, to, insert: ""}); + /** Line number ranges (left-closed and right-open) of lines that contain output or error. */ + const lineNumbers = matchAt(0) ? [{begin: 0, end: 1}] : []; + /** + * The index of the first character of each line. + * If the code ends with a newline, the last index is the length of the code. + */ + const lineStartIndices = [0]; + let nextNewlineIndex = code.indexOf("\n", 0); + while (0 <= nextNewlineIndex && nextNewlineIndex < code.length) { + lineStartIndices.push(nextNewlineIndex + 1); + if (matchAt(nextNewlineIndex + 1)) { + const lineNumber = lineStartIndices.length - 1; + if (lineNumbers.length > 0 && lineNumber === lineNumbers[lineNumbers.length - 1].end) { + // Extend the last line number range. + lineNumbers[lineNumbers.length - 1].end += 1; + } else { + // Append a new line number range. + lineNumbers.push({begin: lineNumber, end: lineNumber + 1}); + } + } + nextNewlineIndex = code.indexOf("\n", nextNewlineIndex + 1); } + const changes = lineNumbers.map(({begin, end}) => ({ + from: lineStartIndices[begin], + to: lineStartIndices[end], + insert: "", + })); + return changes; } @@ -310,6 +368,12 @@ export function createRuntime(initialCode) { const nodes = split(code); if (!nodes) return; + console.groupCollapsed("rerun"); + for (const node of nodes) { + console.log(`Node ${node.type} (${node.start}-${node.end})`); + } + console.groupEnd(); + for (const node of nodes) { const cell = code.slice(node.start, node.end); const transpiled = transpile(cell); @@ -361,7 +425,14 @@ export function createRuntime(initialCode) { for (const node of enter) { const vid = uid(); const {inputs, body, outputs, error = null} = node.transpiled; - const state = {values: [], variables: [], error: null, syntaxError: error, doc: false}; + const state = { + values: [], + variables: [], + error: null, + syntaxError: error, + doc: false, + attributes: Object.create(null), + }; node.state = state; const v = main.variable(observer(state), {shadow: {}}); @@ -385,6 +456,10 @@ export function createRuntime(initialCode) { }; const disposes = []; __echo__.clear = () => clear(state); + __echo__.set = function (key, value) { + state.attributes[key] = value; + return this; + }; __echo__.dispose = (cb) => disposes.push(cb); __echo__.key = (k) => ((options.key = k), __echo__); __echo__.__dispose__ = () => disposes.forEach((cb) => cb()); diff --git a/test/IntervalTree.spec.js b/test/IntervalTree.spec.js new file mode 100644 index 0000000..876c0a4 --- /dev/null +++ b/test/IntervalTree.spec.js @@ -0,0 +1,395 @@ +import {it, expect, describe, beforeEach} from "vitest"; +import {IntervalTree} from "../lib/IntervalTree.ts"; + +describe("IntervalTree", () => { + let tree; + + beforeEach(() => { + tree = new IntervalTree(); + }); + + describe("static from method", () => { + it("should create tree from array of objects with mapper", () => { + const events = [ + {name: "Event A", start: 10, end: 20}, + {name: "Event B", start: 15, end: 25}, + {name: "Event C", start: 30, end: 40}, + ]; + + const tree = IntervalTree.from(events, (event) => ({ + interval: {low: event.start, high: event.end}, + data: event.name, + })); + + expect(tree.length).toBe(3); + expect(tree.isBalanced()).toBe(true); + + const results = tree.search({low: 18, high: 22}); + expect(results.length).toBe(2); + expect(results.map((r) => r.data).sort()).toEqual(["Event A", "Event B"]); + }); + + it("should create tree from array of numbers", () => { + const numbers = [1, 2, 3, 4, 5]; + + const tree = IntervalTree.from(numbers, (num) => ({ + interval: {low: num, high: num + 10}, + data: num * 2, + })); + + expect(tree.length).toBe(5); + // Intervals: [1,11], [2,12], [3,13], [4,14], [5,15] + // Searching [12, 13] overlaps with [2,12], [3,13], [4,14], [5,15] + const results = tree.search({low: 12, high: 13}); + expect(results.map((r) => r.data).sort((a, b) => a - b)).toEqual([4, 6, 8, 10]); + }); + + it("should create empty tree from empty array", () => { + const tree = IntervalTree.from([], (item) => ({ + interval: {low: 0, high: 0}, + data: item, + })); + + expect(tree.isEmpty()).toBe(true); + expect(tree.length).toBe(0); + }); + + it("should handle complex data types", () => { + const tasks = [ + {id: 1, timeRange: [0, 100], priority: "high"}, + {id: 2, timeRange: [50, 150], priority: "medium"}, + {id: 3, timeRange: [120, 200], priority: "low"}, + ]; + + const tree = IntervalTree.from(tasks, (task) => ({ + interval: {low: task.timeRange[0], high: task.timeRange[1]}, + data: {id: task.id, priority: task.priority}, + })); + + expect(tree.length).toBe(3); + + // Intervals: [0,100], [50,150], [120,200] + // Searching [75, 125] overlaps with all three + const results = tree.search({low: 75, high: 125}); + expect(results.length).toBe(3); + expect(results.find((r) => r.data.id === 1)).toBeDefined(); + expect(results.find((r) => r.data.id === 2)).toBeDefined(); + expect(results.find((r) => r.data.id === 3)).toBeDefined(); + }); + }); + + describe("basic operations", () => { + it("should create an empty tree", () => { + expect(tree.isEmpty()).toBe(true); + expect(tree.length).toBe(0); + }); + + it("should insert intervals", () => { + tree.insert({low: 15, high: 20}, "data1"); + expect(tree.isEmpty()).toBe(false); + expect(tree.length).toBe(1); + + tree.insert({low: 10, high: 30}, "data2"); + expect(tree.length).toBe(2); + }); + + it("should reject invalid intervals", () => { + expect(() => tree.insert({low: 20, high: 10}, "data")).toThrow( + "Invalid interval: low must be less than or equal to high", + ); + }); + + it("should allow single-point intervals", () => { + tree.insert({low: 5, high: 5}, "point"); + expect(tree.length).toBe(1); + }); + + it("should delete intervals", () => { + tree.insert({low: 15, high: 20}, "data1"); + tree.insert({low: 10, high: 30}, "data2"); + tree.insert({low: 17, high: 19}, "data3"); + + const deleted = tree.delete({low: 10, high: 30}); + expect(deleted).toBe(true); + expect(tree.length).toBe(2); + + const notDeleted = tree.delete({low: 100, high: 200}); + expect(notDeleted).toBe(false); + expect(tree.length).toBe(2); + }); + + it("should clear the tree", () => { + tree.insert({low: 15, high: 20}, "data1"); + tree.insert({low: 10, high: 30}, "data2"); + tree.clear(); + expect(tree.isEmpty()).toBe(true); + expect(tree.length).toBe(0); + }); + }); + + describe("contains method", () => { + beforeEach(() => { + tree.insert({low: 15, high: 20}, "interval1"); + tree.insert({low: 10, high: 30}, "interval2"); + tree.insert({low: 17, high: 19}, "interval3"); + tree.insert({low: 5, high: 20}, "interval4"); + tree.insert({low: 12, high: 15}, "interval5"); + tree.insert({low: 30, high: 40}, "interval6"); + }); + + it("should find an interval containing a point", () => { + const result = tree.contains(18); + expect(result).not.toBeNull(); + expect(result.interval.low).toBeLessThanOrEqual(18); + expect(result.interval.high).toBeGreaterThanOrEqual(18); + }); + + it("should return null when no interval contains the point", () => { + const result = tree.contains(45); + expect(result).toBeNull(); + }); + + it("should find interval at lower boundary", () => { + const result = tree.contains(15); + expect(result).not.toBeNull(); + expect(result.interval.low).toBeLessThanOrEqual(15); + expect(result.interval.high).toBeGreaterThanOrEqual(15); + }); + + it("should find interval at upper boundary", () => { + const result = tree.contains(40); + expect(result).not.toBeNull(); + expect(result.data).toBe("interval6"); + }); + + it("should find single-point interval", () => { + tree.clear(); + tree.insert({low: 25, high: 25}, "point"); + const result = tree.contains(25); + expect(result).not.toBeNull(); + expect(result.data).toBe("point"); + }); + + it("should return null for point just outside intervals", () => { + tree.clear(); + tree.insert({low: 10, high: 20}, "range"); + expect(tree.contains(9)).toBeNull(); + expect(tree.contains(21)).toBeNull(); + }); + + it("should work with negative numbers", () => { + tree.clear(); + tree.insert({low: -100, high: -50}, "negative"); + tree.insert({low: -10, high: 10}, "crosses-zero"); + + const result1 = tree.contains(-75); + expect(result1).not.toBeNull(); + expect(result1.data).toBe("negative"); + + const result2 = tree.contains(0); + expect(result2).not.toBeNull(); + expect(result2.data).toBe("crosses-zero"); + }); + + it("should handle overlapping intervals efficiently", () => { + tree.clear(); + // Insert multiple overlapping intervals + tree.insert({low: 0, high: 100}, "large"); + tree.insert({low: 40, high: 60}, "medium"); + tree.insert({low: 48, high: 52}, "small"); + + // Should find at least one + const result = tree.contains(50); + expect(result).not.toBeNull(); + expect(result.interval.low).toBeLessThanOrEqual(50); + expect(result.interval.high).toBeGreaterThanOrEqual(50); + }); + }); + + describe("overlap detection", () => { + beforeEach(() => { + tree.insert({low: 15, high: 20}, "interval1"); + tree.insert({low: 10, high: 30}, "interval2"); + tree.insert({low: 17, high: 19}, "interval3"); + tree.insert({low: 5, high: 20}, "interval4"); + tree.insert({low: 12, high: 15}, "interval5"); + tree.insert({low: 30, high: 40}, "interval6"); + }); + + it("should find all overlapping intervals", () => { + const results = tree.search({low: 14, high: 16}); + expect(results.length).toBe(4); + + const dataValues = results.map((r) => r.data).sort(); + expect(dataValues).toEqual(["interval1", "interval2", "interval4", "interval5"]); + }); + + it("should find single overlapping interval", () => { + const result = tree.findAny({low: 35, high: 37}); + expect(result).not.toBeNull(); + expect(result.data).toBe("interval6"); + }); + + it("should return empty array when no overlaps exist", () => { + const results = tree.search({low: 41, high: 50}); + expect(results.length).toBe(0); + }); + + it("should return null when no single overlap exists", () => { + const result = tree.findAny({low: 41, high: 50}); + expect(result).toBeNull(); + }); + + it("should detect overlap with single-point intervals", () => { + tree.clear(); + tree.insert({low: 10, high: 10}, "point"); + tree.insert({low: 5, high: 15}, "range"); + + const results = tree.search({low: 10, high: 10}); + expect(results.length).toBe(2); + }); + + it("should detect edge overlaps", () => { + tree.clear(); + tree.insert({low: 10, high: 20}, "edge1"); + + // Overlaps at left edge + const leftEdge = tree.search({low: 5, high: 10}); + expect(leftEdge.length).toBe(1); + + // Overlaps at right edge + const rightEdge = tree.search({low: 20, high: 25}); + expect(rightEdge.length).toBe(1); + + // No overlap just outside + const noOverlap = tree.search({low: 21, high: 25}); + expect(noOverlap.length).toBe(0); + }); + }); + + describe("tree structure", () => { + it("should maintain balance after insertions", () => { + // Insert intervals in sorted order (worst case for unbalanced tree) + for (let i = 0; i < 100; i++) { + tree.insert({low: i, high: i + 5}, `data${i}`); + } + expect(tree.isBalanced()).toBe(true); + expect(tree.length).toBe(100); + }); + + it("should maintain balance after deletions", () => { + const intervals = []; + for (let i = 0; i < 50; i++) { + const interval = {low: i, high: i + 5}; + intervals.push(interval); + tree.insert(interval, `data${i}`); + } + + // Delete every other interval + for (let i = 0; i < 50; i += 2) { + tree.delete(intervals[i]); + } + + expect(tree.isBalanced()).toBe(true); + expect(tree.length).toBe(25); + }); + }); + + describe("toArray", () => { + it("should return all intervals in sorted order", () => { + tree.insert({low: 15, high: 20}, "data1"); + tree.insert({low: 10, high: 30}, "data2"); + tree.insert({low: 17, high: 19}, "data3"); + tree.insert({low: 5, high: 8}, "data4"); + + const array = tree.toArray(); + expect(array.length).toBe(4); + + // Check that intervals are sorted by low value + for (let i = 0; i < array.length - 1; i++) { + expect(array[i].interval.low).toBeLessThanOrEqual(array[i + 1].interval.low); + } + }); + + it("should return empty array for empty tree", () => { + const array = tree.toArray(); + expect(array).toEqual([]); + }); + }); + + describe("complex scenarios", () => { + it("should handle many overlapping intervals", () => { + // Create many overlapping intervals centered around [50, 60] + for (let i = 0; i < 20; i++) { + tree.insert({low: 45 + i, high: 55 + i}, `overlap${i}`); + } + + const results = tree.search({low: 50, high: 60}); + expect(results.length).toBeGreaterThan(10); + }); + + it("should handle intervals with large ranges", () => { + tree.insert({low: 0, high: 1000000}, "large"); + tree.insert({low: 500, high: 600}, "small"); + + const results = tree.search({low: 550, high: 560}); + expect(results.length).toBe(2); + }); + + it("should handle negative intervals", () => { + tree.insert({low: -100, high: -50}, "negative1"); + tree.insert({low: -75, high: -25}, "negative2"); + tree.insert({low: -10, high: 10}, "crosses-zero"); + + const results = tree.search({low: -60, high: -40}); + expect(results.length).toBe(2); + + const crossResults = tree.search({low: -5, high: 5}); + expect(crossResults.length).toBe(1); + expect(crossResults[0].data).toBe("crosses-zero"); + }); + + it("should work with different data types", () => { + const tree2 = new IntervalTree(); + + tree2.insert({low: 1, high: 5}, {name: "Alice", age: 30}); + tree2.insert({low: 3, high: 7}, {name: "Bob", age: 25}); + + const results = tree2.search({low: 4, high: 6}); + expect(results.length).toBe(2); + expect(results.find((r) => r.data.name === "Alice")).toBeDefined(); + expect(results.find((r) => r.data.name === "Bob")).toBeDefined(); + }); + }); + + describe("stress test", () => { + it("should handle large number of random intervals efficiently", () => { + const intervals = []; + + // Insert 1000 random intervals + for (let i = 0; i < 1000; i++) { + const low = Math.floor(Math.random() * 10000); + const high = low + Math.floor(Math.random() * 100) + 1; + const interval = {low, high}; + intervals.push(interval); + tree.insert(interval, `data${i}`); + } + + expect(tree.length).toBe(1000); + expect(tree.isBalanced()).toBe(true); + + // Perform random searches + for (let i = 0; i < 100; i++) { + const searchLow = Math.floor(Math.random() * 10000); + const searchHigh = searchLow + Math.floor(Math.random() * 100) + 1; + const results = tree.search({low: searchLow, high: searchHigh}); + + // Verify all results actually overlap + for (const result of results) { + const overlaps = result.interval.low <= searchHigh && searchLow <= result.interval.high; + expect(overlaps).toBe(true); + } + } + }); + }); +}); diff --git a/test/blocks.spec.ts b/test/blocks.spec.ts new file mode 100644 index 0000000..46c1c51 --- /dev/null +++ b/test/blocks.spec.ts @@ -0,0 +1,294 @@ +import {it, expect, describe} from "vitest"; +import {findAdjacentBlocks} from "../lib/blocks.js"; + +describe("findAdjacentBlocks", () => { + describe("with sorted non-continuous ranges", () => { + it("should handle comprehensive test with all possible positions", () => { + // Generate sorted but non-continuous ranges + // For example: [2, 5), [7, 10), [15, 18), [20, 23) + const blocks = [ + {from: 2, to: 5}, + {from: 7, to: 10}, + {from: 15, to: 18}, + {from: 20, to: 23}, + ]; + + const maxPos = blocks[blocks.length - 1]!.to; + + // Test every position from 0 to maxPos + 1 + for (let pos = 0; pos <= maxPos + 1; pos++) { + const result = findAdjacentBlocks(blocks, pos); + + // Determine expected result + let expectedResult; + + // Check if pos is inside any block + const blockIndex = blocks.findIndex((block) => pos >= block.from && pos < block.to); + + if (blockIndex !== -1) { + // pos is inside a block + expectedResult = blockIndex; + } else { + // pos is not inside any block, find adjacent blocks + let beforeBlock = null; + let afterBlock = null; + + for (let i = 0; i < blocks.length; i++) { + if (blocks[i]!.to <= pos) { + beforeBlock = i; + } + } + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i]!.from > pos) { + afterBlock = i; + } + } + + if (beforeBlock === null) { + if (afterBlock === null) { + expectedResult = null; + } else { + expectedResult = [afterBlock - 1, afterBlock]; + } + } else if (afterBlock === null) { + expectedResult = [beforeBlock, beforeBlock + 1]; + } else { + expectedResult = [beforeBlock, afterBlock]; + } + } + + expect(result).toEqual(expectedResult); + } + }); + + it("should handle position 0 before first block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 0)).toEqual([-1, 0]); + }); + + it("should handle position 1 before first block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 1)).toEqual([-1, 0]); + }); + + it("should handle position 2 at start of block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 2)).toBe(0); + }); + + it("should handle position 3 inside block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 3)).toBe(0); + }); + + it("should handle position 4 at end-1 of block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 4)).toBe(0); + }); + + it("should handle position 5 after block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 5)).toEqual([0, 1]); + }); + + it("should handle position 6 after block [2, 5)", () => { + const blocks = [{from: 2, to: 5}]; + expect(findAdjacentBlocks(blocks, 6)).toEqual([0, 1]); + }); + + it("should handle gap between two blocks", () => { + const blocks = [ + {from: 2, to: 5}, + {from: 7, to: 10}, + ]; + expect(findAdjacentBlocks(blocks, 5)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 6)).toEqual([0, 1]); + }); + + it("should handle position inside second block", () => { + const blocks = [ + {from: 2, to: 5}, + {from: 7, to: 10}, + ]; + expect(findAdjacentBlocks(blocks, 7)).toBe(1); + expect(findAdjacentBlocks(blocks, 8)).toBe(1); + expect(findAdjacentBlocks(blocks, 9)).toBe(1); + }); + + it("should handle position after all blocks", () => { + const blocks = [ + {from: 2, to: 5}, + {from: 7, to: 10}, + ]; + expect(findAdjacentBlocks(blocks, 10)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 100)).toEqual([1, 2]); + }); + + it("should handle multiple blocks with various positions", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + {from: 25, to: 30}, + ]; + + // Before all blocks + expect(findAdjacentBlocks(blocks, 0)).toEqual([-1, 0]); + expect(findAdjacentBlocks(blocks, 4)).toEqual([-1, 0]); + + // Inside first block + expect(findAdjacentBlocks(blocks, 5)).toBe(0); + expect(findAdjacentBlocks(blocks, 7)).toBe(0); + expect(findAdjacentBlocks(blocks, 9)).toBe(0); + + // Between first and second block + expect(findAdjacentBlocks(blocks, 10)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 12)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 14)).toEqual([0, 1]); + + // Inside second block + expect(findAdjacentBlocks(blocks, 15)).toBe(1); + expect(findAdjacentBlocks(blocks, 17)).toBe(1); + expect(findAdjacentBlocks(blocks, 19)).toBe(1); + + // Between second and third block + expect(findAdjacentBlocks(blocks, 20)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 22)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 24)).toEqual([1, 2]); + + // Inside third block + expect(findAdjacentBlocks(blocks, 25)).toBe(2); + expect(findAdjacentBlocks(blocks, 27)).toBe(2); + expect(findAdjacentBlocks(blocks, 29)).toBe(2); + + // After all blocks + expect(findAdjacentBlocks(blocks, 30)).toEqual([2, 3]); + expect(findAdjacentBlocks(blocks, 50)).toEqual([2, 3]); + }); + }); + + describe("edge cases", () => { + it("should handle empty blocks array", () => { + const blocks: [] = []; + expect(findAdjacentBlocks(blocks, 0)).toEqual(null); + expect(findAdjacentBlocks(blocks, 5)).toEqual(null); + expect(findAdjacentBlocks(blocks, 100)).toEqual(null); + }); + + it("should handle single block", () => { + const blocks = [{from: 10, to: 20}]; + + expect(findAdjacentBlocks(blocks, 0)).toEqual([-1, 0]); + expect(findAdjacentBlocks(blocks, 9)).toEqual([-1, 0]); + expect(findAdjacentBlocks(blocks, 10)).toBe(0); + expect(findAdjacentBlocks(blocks, 15)).toBe(0); + expect(findAdjacentBlocks(blocks, 19)).toBe(0); + expect(findAdjacentBlocks(blocks, 20)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 25)).toEqual([0, 1]); + }); + + it("should handle adjacent blocks (no gap)", () => { + const blocks = [ + {from: 0, to: 5}, + {from: 5, to: 10}, + ]; + + expect(findAdjacentBlocks(blocks, 4)).toBe(0); + expect(findAdjacentBlocks(blocks, 5)).toBe(1); + expect(findAdjacentBlocks(blocks, 6)).toBe(1); + }); + + it("should handle blocks starting at 0", () => { + const blocks = [ + {from: 0, to: 3}, + {from: 5, to: 8}, + ]; + + expect(findAdjacentBlocks(blocks, 0)).toBe(0); + expect(findAdjacentBlocks(blocks, 1)).toBe(0); + expect(findAdjacentBlocks(blocks, 2)).toBe(0); + expect(findAdjacentBlocks(blocks, 3)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 4)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 5)).toBe(1); + }); + + it("should handle single-width blocks", () => { + const blocks = [ + {from: 0, to: 1}, + {from: 3, to: 4}, + {from: 6, to: 7}, + ]; + + expect(findAdjacentBlocks(blocks, 0)).toBe(0); + expect(findAdjacentBlocks(blocks, 1)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 2)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 3)).toBe(1); + expect(findAdjacentBlocks(blocks, 4)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 5)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 6)).toBe(2); + expect(findAdjacentBlocks(blocks, 7)).toEqual([2, 3]); + }); + + it("should handle large gaps between blocks", () => { + const blocks = [ + {from: 0, to: 5}, + {from: 100, to: 105}, + {from: 1000, to: 1005}, + ]; + + expect(findAdjacentBlocks(blocks, 50)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 99)).toEqual([0, 1]); + expect(findAdjacentBlocks(blocks, 100)).toBe(1); + expect(findAdjacentBlocks(blocks, 500)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 999)).toEqual([1, 2]); + expect(findAdjacentBlocks(blocks, 1000)).toBe(2); + expect(findAdjacentBlocks(blocks, 2000)).toEqual([2, 3]); + }); + }); + + function generateBlocks(blockCount: number) { + const blocks = []; + let currentPos = Math.floor(Math.random() * 5); + + for (let i = 0; i < blockCount; i++) { + const width = Math.floor(Math.random() * 5) + 1; + const gap = Math.floor(Math.random() * 5) + 1; + blocks.push({from: currentPos, to: currentPos + width}); + currentPos += width + gap; + } + + const maxPos = blocks[blocks.length - 1]!.to; + + return {blocks, maxPos}; + } + + describe("randomized comprehensive tests", () => { + it("should handle random sorted non-continuous ranges", () => { + for (let z = 0; z < 10; z++) { + const {blocks, maxPos} = generateBlocks(2 ** z); + + // Test every position from 0 to maxPos + 1 + for (let pos = 0; pos <= maxPos + 1; pos++) { + const result = findAdjacentBlocks(blocks, pos); + + // Verify the result is correct + const blockIndex = blocks.findIndex((block) => pos >= block.from && pos < block.to); + + if (blockIndex !== -1) { + expect(result).toBe(blockIndex); + } else { + expect(Array.isArray(result)).toBe(true); + const [before, after] = result as [number, number]; + + if (0 <= before && before < blocks.length) { + expect(blocks[before]!.to).toBeLessThanOrEqual(pos); + } + if (0 <= after && after < blocks.length) { + expect(blocks[after]!.from).toBeGreaterThan(pos); + } + expect(before + 1).toBe(after); + } + } + } + }); + }); +}); diff --git a/test/blocks/affected.spec.ts b/test/blocks/affected.spec.ts new file mode 100644 index 0000000..54c464f --- /dev/null +++ b/test/blocks/affected.spec.ts @@ -0,0 +1,398 @@ +import {it, expect, describe} from "vitest"; +import {findAffectedBlockRange} from "../../lib/blocks.js"; + +describe("findAffectedBlockRange", () => { + function generateBlocks(blockCount: number) { + const blocks = []; + let currentPos = Math.floor(Math.random() * 5); + + for (let i = 0; i < blockCount; i++) { + const width = Math.floor(Math.random() * 5) + 1; + const gap = Math.floor(Math.random() * 5) + 1; + blocks.push({from: currentPos, to: currentPos + width}); + currentPos += width + gap; + } + + const maxPos = blocks.length > 0 ? blocks[blocks.length - 1]!.to : 0; + + return {blocks, maxPos}; + } + + describe("error cases", () => { + it("should throw when from > to", () => { + const blocks = [{from: 5, to: 10}]; + expect(() => findAffectedBlockRange(blocks, 10, 5)).toThrow("`from` must be less than or equal to `to`"); + }); + + it("should throw when from < 0", () => { + const blocks = [{from: 5, to: 10}]; + expect(() => findAffectedBlockRange(blocks, -1, 5)).toThrow("`from` must be greater than or equal to 0"); + }); + }); + + describe("insertion cases (from === to)", () => { + it("should return [index, index+1) when insertion point is inside a block", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Insert at position 7 (inside first block) + expect(findAffectedBlockRange(blocks, 7, 7)).toEqual([0, 1]); + + // Insert at position 17 (inside second block) + expect(findAffectedBlockRange(blocks, 17, 17)).toEqual([1, 2]); + }); + + it("should return a range contains two blocks when insertion point is between two blocks", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Insert at position 12 (between blocks) + expect(findAffectedBlockRange(blocks, 12, 12)).toStrictEqual([0, 2]); + + // Insert at position 10 (right after first block) + expect(findAffectedBlockRange(blocks, 10, 10)).toStrictEqual([0, 2]); + + // Insert at position 10 (right before first block) + expect(findAffectedBlockRange(blocks, 14, 14)).toStrictEqual([0, 2]); + }); + + it("should return [null, 1] when insertion point is before all blocks", () => { + const blocks = [{from: 5, to: 10}]; + expect(findAffectedBlockRange(blocks, 0, 0)).toStrictEqual([null, 1]); + expect(findAffectedBlockRange(blocks, 3, 3)).toStrictEqual([null, 1]); + }); + + it("should return [N - 1, null] when insertion point is after all blocks", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 12, to: 15}, + ]; + expect(findAffectedBlockRange(blocks, 15, 15)).toStrictEqual([1, null]); + expect(findAffectedBlockRange(blocks, 16, 16)).toStrictEqual([1, null]); + }); + + it("should return [null, null] for empty blocks array", () => { + const blocks: {from: number; to: number}[] = []; + expect(findAffectedBlockRange(blocks, 5, 5)).toStrictEqual([null, null]); + }); + + it("should return two adjacent blocks when insertion point is between them", () => { + const blocks = [ + {from: 2, to: 4}, + {from: 6, to: 11}, + {from: 16, to: 19}, + {from: 24, to: 25}, + {from: 27, to: 30}, + ]; + expect(findAffectedBlockRange(blocks, 4, 4)).toStrictEqual([0, 2]); + }); + }); + + describe("deletion cases (from < to)", () => { + it("should handle range inside a single block", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + {from: 25, to: 30}, + ]; + + // Delete [6, 8) inside first block. We should only re-parse the block. + expect(findAffectedBlockRange(blocks, 6, 8)).toEqual([0, 1]); + + // Delete [16, 19) inside second block. We should only re-parse the block. + expect(findAffectedBlockRange(blocks, 16, 19)).toEqual([1, 2]); + }); + + it("should handle range spanning multiple blocks", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + {from: 25, to: 30}, + ]; + + // Delete [6, 18) spanning first two blocks + expect(findAffectedBlockRange(blocks, 6, 18)).toEqual([0, 2]); + + // Delete [16, 28) spanning second and third blocks + expect(findAffectedBlockRange(blocks, 16, 28)).toEqual([1, 3]); + + // Delete [6, 28) spanning all three blocks + expect(findAffectedBlockRange(blocks, 6, 28)).toEqual([0, 3]); + }); + + it("should handle range from gap to inside block", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Delete [12, 18) from gap into second block. The first block also needs + // to be re-parsed. + expect(findAffectedBlockRange(blocks, 12, 18)).toEqual([0, 2]); + }); + + it("should handle range from inside block to gap", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Delete [7, 12) from first block into gap. The second block also needs + // to be re-parsed because it might be affected by deletion. For example, + // deleting the `//` part of a comment. + expect(findAffectedBlockRange(blocks, 7, 12)).toEqual([0, 2]); + }); + + it("should handle range spanning gap between blocks", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Delete [10, 15) exactly the gap. The deletion of the gap might affect + // its surrounding blocks. + expect(findAffectedBlockRange(blocks, 10, 15)).toEqual([0, 2]); + }); + + it("should return an empty range when range is entirely in a gap", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 20, to: 25}, + ]; + + // Delete [12, 18) entirely in gap of 0 and 1. The deletion of the gap + // might affect its surrounding blocks. + expect(findAffectedBlockRange(blocks, 12, 18)).toStrictEqual([0, 2]); + }); + + it("should return null when range is before all blocks", () => { + const blocks = [{from: 10, to: 15}]; + + // The returned range means "from the beginning of the document to the + // `to` of the first block". + expect(findAffectedBlockRange(blocks, 0, 5)).toStrictEqual([null, 1]); + }); + + it("should return null when range is after all blocks", () => { + const blocks = [{from: 5, to: 10}]; + + // Similar to the previous test, but now the range is after all blocks. + // The returned range means "from the beginning of the last block to the + // end of the document". + expect(findAffectedBlockRange(blocks, 15, 20)).toStrictEqual([0, null]); + }); + + it("should handle range starting at block boundary", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Delete [5, 8) starting at block start. Only the first block is affected. + expect(findAffectedBlockRange(blocks, 5, 8)).toEqual([0, 1]); + + // Delete [10, 18) starting at block end. The deletion happens within a + // gap. Thus, the second block is also affected. + expect(findAffectedBlockRange(blocks, 10, 18)).toEqual([0, 2]); + }); + + it("should handle range ending at block boundary", () => { + const blocks = [ + {from: 5, to: 10}, + {from: 15, to: 20}, + ]; + + // Delete [7, 10) ending at block end + expect(findAffectedBlockRange(blocks, 7, 10)).toStrictEqual([0, 1]); + + // Delete [12, 15) ending at block start + expect(findAffectedBlockRange(blocks, 12, 15)).toStrictEqual([0, 2]); + }); + }); + + type Location = + | {type: "contained"; index: number} + | { + type: "between"; + /** + * Note that `previous` ranges from 0 to `blocks.length - 2`. Thus, + * using `previous + 2` is safe. + */ + previous: number; + } + | {type: "afterAll"} + | {type: "beforeAll"}; + + function locateNaive(blocks: {from: number; to: number}[], pos: number): Location { + let location: Location | null = null; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]!; + if (block.from <= pos && pos < block.to) { + location = {type: "contained", index: i}; + } + } + if (location === null) { + if (pos < blocks[0]!.from) { + location = {type: "beforeAll"}; + } else { + for (let i = 1; i < blocks.length; i++) { + const previous = blocks[i - 1]!; + const next = blocks[i]!; + if (previous.to <= pos && pos < next.from) { + location = {type: "between", previous: i - 1}; + } + } + if (location === null) { + location = {type: "afterAll"}; + } + } + } + return location; + } + + describe("comprehensive random tests", () => { + it("should verify invariants for all valid ranges", () => { + for (let trial = 0; trial < 10; trial++) { + const {blocks, maxPos} = generateBlocks(2 * trial); + + if (blocks.length === 0) continue; + + // Test all valid ranges [from, to) where from <= to + for (let from = 0; from <= maxPos + 1; from++) { + for (let to = from; to <= maxPos + 1; to++) { + const fromLocation = locateNaive(blocks, from); + let expected: [number | null, number | null]; + if (from === to) { + switch (fromLocation.type) { + case "contained": + // Include the block it is contained in. + expected = [fromLocation.index, fromLocation.index + 1]; + break; + case "between": + // Include the adjacent blocks. + expected = [fromLocation.previous, fromLocation.previous + 2]; + break; + case "beforeAll": + // Extend to the beginning of the document (represented by + // `null`) and include the first element. + expected = [null, 1]; + break; + case "afterAll": + // Extend to the end of the document (represented by `null`) + // and include the last element. + expected = [blocks.length - 1, null]; + break; + } + } else { + const toLocation = locateNaive(blocks, to - 1); + let fromIndex: number | null; + switch (fromLocation.type) { + case "contained": + // Just include the block it is contained in. + fromIndex = fromLocation.index; + break; + case "between": + // To include the previous block. + fromIndex = fromLocation.previous; + break; + case "afterAll": + // To include the last element. + fromIndex = blocks.length - 1; + break; + case "beforeAll": + // Extend to the beginning of the document (represented by `null`). + fromIndex = null; + break; + } + let toIndex: number | null; + switch (toLocation.type) { + case "contained": + // To include the block it is contained in. `+1` because the + // `to` is exclusive. + toIndex = toLocation.index + 1; + break; + case "between": + // To include the block it is contained in. `+2` because the + // `to` is exclusive and it is the previous block's index. + toIndex = toLocation.previous + 2; + break; + case "afterAll": + // Extend to the end of the document (represented by `null`). + toIndex = null; + break; + case "beforeAll": + // Include the first block. `1` because the `to` is exclusive. + toIndex = 1; + break; + } + expected = [fromIndex, toIndex]; + } + + const result = findAffectedBlockRange(blocks, from, to); + + expect( + result, + `Expected affected block range to be [${expected[0]}, ${expected[1]}): (blocks = ${JSON.stringify(blocks)}, from = ${from}, to = ${to})`, + ).toStrictEqual(expected); + } + } + } + }); + + it("should handle edge cases with adjacent blocks", () => { + const blocks = [ + {from: 0, to: 5}, + {from: 5, to: 10}, + {from: 10, to: 15}, + ]; + + // Range exactly covering one block + expect(findAffectedBlockRange(blocks, 0, 5)).toEqual([0, 1]); + expect(findAffectedBlockRange(blocks, 5, 10)).toEqual([1, 2]); + + // Range covering multiple adjacent blocks + expect(findAffectedBlockRange(blocks, 0, 10)).toEqual([0, 2]); + expect(findAffectedBlockRange(blocks, 5, 15)).toEqual([1, 3]); + expect(findAffectedBlockRange(blocks, 0, 15)).toEqual([0, 3]); + + // Single point at boundary + expect(findAffectedBlockRange(blocks, 5, 5)).toEqual([1, 2]); + expect(findAffectedBlockRange(blocks, 10, 10)).toEqual([2, 3]); + }); + + it("should handle single block edge cases", () => { + const blocks = [{from: 10, to: 20}]; + + // Range inside block + expect(findAffectedBlockRange(blocks, 12, 15)).toEqual([0, 1]); + + // Range covering entire block + expect(findAffectedBlockRange(blocks, 10, 20)).toEqual([0, 1]); + + // Range overlapping start + expect(findAffectedBlockRange(blocks, 5, 15)).toEqual([null, 1]); + + // Range overlapping end + expect(findAffectedBlockRange(blocks, 15, 25)).toEqual([0, null]); + + // Range covering block and beyond + expect(findAffectedBlockRange(blocks, 5, 25)).toEqual([null, null]); + + // Range before block + expect(findAffectedBlockRange(blocks, 0, 5)).toStrictEqual([null, 1]); + + // Range after block + expect(findAffectedBlockRange(blocks, 25, 30)).toStrictEqual([0, null]); + + // Range touching start boundary + expect(findAffectedBlockRange(blocks, 5, 10)).toStrictEqual([null, 1]); + + // Range touching end boundary + expect(findAffectedBlockRange(blocks, 20, 25)).toStrictEqual([0, null]); + }); + }); +}); diff --git a/test/blocks/detect.spec.ts b/test/blocks/detect.spec.ts new file mode 100644 index 0000000..8051c66 --- /dev/null +++ b/test/blocks/detect.spec.ts @@ -0,0 +1,38 @@ +import {parser} from "@lezer/javascript"; +import {detectBlocksWithinRange} from "../../lib/blocks/detect.js"; +import {it, expect, describe} from "vitest"; +import {Text} from "@codemirror/state"; + +describe("block detection simple test", () => { + it("test parse", () => { + const code = "const x = 0;"; + const tree = parser.parse(code); + expect(tree).toBeDefined(); + }); + + function viewEachLineAsBlock(lines: string[]) { + const blocks = []; + for (let i = 0, from = 0; i < lines.length; i++) { + const line = lines[i]!; + blocks.push({from, to: from + line.length}); + from += line.length + 1; + } + return blocks; + } + + it("should detect two blocks in a full parse", () => { + const lines = [`const code = "const x = 0;";`, `const y = 1;`]; + const doc = Text.of(lines); + const tree = parser.parse(doc.toString()); + const blocks = detectBlocksWithinRange(tree, doc, 0, doc.length); + expect(blocks).toMatchObject(viewEachLineAsBlock(lines)); + }); + + it("should detect the first block in a partial parse", () => { + const lines = [`const code = "const x = 0;";`, `const y = 1;`]; + const doc = Text.of(lines); + const tree = parser.parse(doc.toString()); + const blocks = detectBlocksWithinRange(tree, doc, 0, 5); + expect(blocks).toMatchObject(viewEachLineAsBlock(lines).slice(0, 1)); + }); +}); diff --git a/test/components/App.tsx b/test/components/App.tsx new file mode 100644 index 0000000..35d9955 --- /dev/null +++ b/test/components/App.tsx @@ -0,0 +1,66 @@ +import type {PluginValue, ViewPlugin} from "@codemirror/view"; +import {useAtom} from "jotai"; +import {useEffect, useState} from "react"; +import {Panel, PanelGroup, PanelResizeHandle} from "react-resizable-panels"; +import * as testSamples from "../js/index.js"; +import {selectedTestAtom} from "../store.ts"; +import {BlockViewer} from "./BlockViewer.tsx"; +import {Editor} from "./Editor.tsx"; +import {TestSelector} from "./TestSelector.tsx"; +import {TransactionViewer} from "./TransactionViewer.tsx"; + +export function App() { + const [selectedTest, setSelectedTest] = useAtom(selectedTestAtom); + const [transactionViewerPlugin, setTransactionViewerPlugin] = useState | undefined>(); + const [blockViewerPlugin, setBlockViewerPlugin] = useState | undefined>(); + + // Initialize from URL on mount + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const testFromUrl = params.get("test"); + if (testFromUrl && testFromUrl in testSamples) { + setSelectedTest(testFromUrl); + } + }, [setSelectedTest]); + + // Get the current test code + const currentCode = (testSamples as Record)[selectedTest] || testSamples.helloWorld; + + return ( +
+ {/* Header */} +
+
+

Recho Playground

+ +
+
+ + + +
+ +
+
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/test/components/BlockItem.tsx b/test/components/BlockItem.tsx new file mode 100644 index 0000000..1c6765c --- /dev/null +++ b/test/components/BlockItem.tsx @@ -0,0 +1,87 @@ +import {CircleXIcon, Locate, SquareTerminalIcon} from "lucide-react"; +import {useState} from "react"; +import type {BlockData} from "./types.ts"; + +export function BlockItem({block, onLocate}: {block: BlockData; onLocate: (block: BlockData) => void}) { + const [isOpen, setIsOpen] = useState(false); + + const hasOutput = block.outputFrom !== null && block.outputTo !== null; + const hasAttributes = Object.keys(block.attributes).length > 0; + + return ( +
setIsOpen((e.target as HTMLDetailsElement).open)} + className={`mb-2 border rounded text-xs ${ + block.hasError + ? "border-red-300 bg-red-50" + : hasOutput + ? "border-green-300 bg-green-50" + : "border-gray-200 bg-white" + }`} + > + +
+ {block.index + 1} + {block.hasError && } + {hasOutput && !block.hasError && } +
+ {block.name} +
+ + [ + {block.sourceFrom}, + {block.sourceTo} + ) + + +
+
+
+
+ Source Range: {block.sourceFrom}-{block.sourceTo} + ({block.sourceTo - block.sourceFrom} chars) +
+ + {hasOutput ? ( +
+ Output Range: {block.outputFrom}-{block.outputTo} + ({block.outputTo! - block.outputFrom!} chars) +
+ ) : ( +
+ Output Range: none +
+ )} + +
+ Total Range: {hasOutput ? block.outputFrom : block.sourceFrom}-{block.sourceTo} + + ({block.sourceTo - (hasOutput ? block.outputFrom! : block.sourceFrom)} chars) + +
+ + {block.hasError &&
✗ Has Error
} + + {hasAttributes && ( +
+ Attributes: +
+ {JSON.stringify(block.attributes, null, 2)} +
+
+ )} +
+
+ ); +} diff --git a/test/components/BlockViewer.tsx b/test/components/BlockViewer.tsx new file mode 100644 index 0000000..7826cb9 --- /dev/null +++ b/test/components/BlockViewer.tsx @@ -0,0 +1,123 @@ +import {EditorSelection, type Transaction} from "@codemirror/state"; +import {EditorView, ViewPlugin, ViewUpdate, type PluginValue} from "@codemirror/view"; +import {useEffect, useRef, useState} from "react"; +import {blockMetadataEffect, blockMetadataField} from "../../editor/blockMetadata.ts"; +import type {BlockData} from "./types.ts"; +import {BlockItem} from "./BlockItem.tsx"; + +interface BlockViewerProps { + onPluginCreate: (plugin: ViewPlugin) => void; +} + +export function BlockViewer({onPluginCreate}: BlockViewerProps) { + const [blocks, setBlocks] = useState([]); + const [autoScroll, setAutoScroll] = useState(true); + const listRef = useRef(null); + const viewRef = useRef(null); + + useEffect(() => { + let currentBlocks: BlockData[] = []; + const listeners = new Set<(blocks: BlockData[]) => void>(); + + function notifyListeners() { + listeners.forEach((fn) => fn([...currentBlocks])); + } + + function extractBlockData(view: EditorView): BlockData[] { + const blockMetadata = view.state.field(blockMetadataField, false); + if (!blockMetadata) return []; + + return blockMetadata.map((block, index) => ({ + name: block.name, + index, + sourceFrom: block.source.from, + sourceTo: block.source.to, + outputFrom: block.output?.from ?? null, + outputTo: block.output?.to ?? null, + hasError: block.error || false, + attributes: block.attributes || {}, + })); + } + + const plugin = ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + viewRef.current = view; + currentBlocks = extractBlockData(view); + notifyListeners(); + } + + update(update: ViewUpdate) { + viewRef.current = update.view; + if ( + update.docChanged || + update.transactions.some((tr: Transaction) => tr.effects.some((effect) => effect.is(blockMetadataEffect))) + ) { + currentBlocks = extractBlockData(update.view); + notifyListeners(); + } + } + }, + ); + + listeners.add((newBlocks) => { + setBlocks(newBlocks); + }); + + onPluginCreate(plugin); + + return () => { + listeners.clear(); + }; + }, [onPluginCreate]); + + useEffect(() => { + if (autoScroll && listRef.current) { + listRef.current.scrollTop = 0; + } + }, [blocks, autoScroll]); + + const handleLocateBlock = (block: BlockData) => { + if (!viewRef.current) return; + + const view = viewRef.current; + // The block starts at output.from (if output exists) or source.from + // The block ends at source.to + const from = block.outputFrom ?? block.sourceFrom; + const to = block.sourceTo; + + // Scroll the block into view and select the range + view.dispatch({ + effects: EditorView.scrollIntoView(from, {y: "center"}), + selection: EditorSelection.range(from, to), + }); + + view.focus(); + }; + + return ( +
+
+

Blocks ({blocks.length})

+
+ +
+
+
+ {blocks.length === 0 ? ( +
No blocks yet
+ ) : ( + blocks.map((block) => ) + )} +
+
+ ); +} diff --git a/test/components/Editor.tsx b/test/components/Editor.tsx new file mode 100644 index 0000000..a3b204c --- /dev/null +++ b/test/components/Editor.tsx @@ -0,0 +1,142 @@ +import {useEffect, useRef, useState} from "react"; +import {Play, Square, RefreshCcw} from "lucide-react"; +import {createEditor} from "../../editor/index.js"; +import {cn} from "../../app/cn.js"; +import type {PluginValue, ViewPlugin} from "@codemirror/view"; + +interface EditorProps { + className?: string; + code: string; + transactionViewerPlugin?: ViewPlugin; + blockViewerPlugin?: ViewPlugin; + onError?: (error: Error) => void; +} + +function debounce(fn: (...args: Args) => void, delay = 0) { + let timeout: ReturnType; + return (...args: Args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }; +} + +const onDefaultError = debounce(() => { + setTimeout(() => { + alert("Something unexpected happened. Please check the console for details."); + }, 100); +}, 0); + +type EditorRuntime = { + run: () => void; + stop: () => void; + on: (event: unknown, callback: () => void) => unknown; + destroy: () => void; +}; + +export function Editor({ + className, + code, + transactionViewerPlugin, + blockViewerPlugin, + onError = onDefaultError, +}: EditorProps) { + const containerRef = useRef(null); + const editorRef = useRef(null); + const [needRerun, setNeedRerun] = useState(false); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.innerHTML = ""; + + const extensions = []; + if (transactionViewerPlugin) extensions.push(transactionViewerPlugin); + if (blockViewerPlugin) extensions.push(blockViewerPlugin); + + editorRef.current = createEditor(containerRef.current, { + code, + extensions, + onError, + }); + + // Auto-run on mount + editorRef.current.run(); + } + + return () => { + if (editorRef.current) { + editorRef.current.destroy(); + } + }; + }, [code, transactionViewerPlugin, blockViewerPlugin, onError]); + + useEffect(() => { + const onInput = () => { + setNeedRerun(true); + }; + + if (editorRef.current) { + editorRef.current.on("userInput", onInput); + } + }, [code]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey && e.key === "s") { + e.preventDefault(); + onRun(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + function onRun() { + setNeedRerun(false); + editorRef.current?.run(); + } + + function onStop() { + setNeedRerun(false); + editorRef.current?.stop(); + } + + function onRerun() { + setNeedRerun(false); + editorRef.current?.stop(); + editorRef.current?.run(); + } + + function metaKey() { + return typeof navigator !== "undefined" && navigator.userAgent.includes("Mac") ? "⌘" : "Ctrl"; + } + + return ( +
+
+
Editor
+
+ + + +
+
+
+
{code}
+
+
+ ); +} diff --git a/test/components/SelectionGroupItem.tsx b/test/components/SelectionGroupItem.tsx new file mode 100644 index 0000000..6a49caa --- /dev/null +++ b/test/components/SelectionGroupItem.tsx @@ -0,0 +1,61 @@ +import {useState} from "react"; +import type {TransactionData} from "./types.ts"; + +export function SelectionGroupItem({transactions}: {transactions: TransactionData[]}) { + const [isOpen, setIsOpen] = useState(false); + + const formatTime = (timestamp: number) => { + const time = new Date(timestamp); + return ( + time.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + "." + + time.getMilliseconds().toString().padStart(3, "0") + ); + }; + + const count = transactions.length; + const firstTr = transactions[0]!; + const lastTr = transactions[transactions.length - 1]!; + + const summaryLeft = `#${lastTr.index}-${firstTr.index} [select] (${count} transactions)`; + const summaryRight = `${formatTime(lastTr.timestamp)} - ${formatTime(firstTr.timestamp)}`; + + return ( +
setIsOpen((e.target as HTMLDetailsElement).open)} + className="mb-2 border border-gray-300 rounded text-xs bg-gray-50" + > + + {summaryLeft} + {summaryRight} + +
+ {transactions.map((tr) => { + const selectionInfo = tr.selection + .map((range) => { + const isCursor = range.from === range.to; + return isCursor ? `cursor at ${range.from}` : `${range.from}-${range.to}`; + }) + .join(", "); + + return ( +
+ #{tr.index} + {selectionInfo} + {formatTime(tr.timestamp)} +
+ ); + })} +
+
+ ); +} diff --git a/test/components/TestSelector.tsx b/test/components/TestSelector.tsx new file mode 100644 index 0000000..11bbeb1 --- /dev/null +++ b/test/components/TestSelector.tsx @@ -0,0 +1,30 @@ +import {useAtom} from "jotai"; +import {selectedTestAtom, getTestSampleName} from "../store.ts"; +import * as testSamples from "../js/index.js"; + +export function TestSelector() { + const [selectedTest, setSelectedTest] = useAtom(selectedTestAtom); + + const handleChange = (e: React.ChangeEvent) => { + const newTest = e.target.value; + setSelectedTest(newTest); + // Update URL + const url = new URL(window.location.href); + url.searchParams.set("test", newTest); + window.history.pushState({}, "", url); + }; + + return ( + + ); +} diff --git a/test/components/TransactionItem.tsx b/test/components/TransactionItem.tsx new file mode 100644 index 0000000..9219d6b --- /dev/null +++ b/test/components/TransactionItem.tsx @@ -0,0 +1,165 @@ +import {useState, type ReactNode} from "react"; +import type {TransactionData} from "./types.ts"; +import {ObjectInspector} from "react-inspector"; +import {cn} from "../../app/cn.js"; +import {UserEvent} from "./UserEvent.tsx"; +import {PencilLineIcon, RefreshCcw} from "lucide-react"; + +export function TransactionItem({transaction: tr}: {transaction: TransactionData}) { + const [isOpen, setIsOpen] = useState(false); + + const formatTime = (timestamp: number) => { + const time = new Date(timestamp); + return ( + time.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + "." + + time.getMilliseconds().toString().padStart(3, "0") + ); + }; + + const summaryNodes: ReactNode[] = [ + + #{tr.index} + , + ]; + + if (typeof tr.annotations.userEvent === "string") { + summaryNodes.push(); + } else if (tr.annotations.remote) { + summaryNodes.push(); + } + if (tr.docChanged) { + summaryNodes.push(); + } + + return ( +
setIsOpen((e.target as HTMLDetailsElement).open)} + className={cn( + "mb-2 border border-gray-200 rounded text-xs", + tr.annotations.userEvent && "bg-blue-50", + tr.docChanged && "border-l-4 border-l-blue-500", + )} + > + + {summaryNodes} + {formatTime(tr.timestamp)} + +
+
+ Doc Changed: {tr.docChanged.toString()} +
+ + {tr.changes.length > 0 ? ( +
+ Changes: + {tr.changes.map((change, idx) => { + const deleted = change.to - change.from; + const inserted = change.insert.length; + const sameLine = change.fromLine === change.toLine; + + let posInfo = `pos ${change.from}-${change.to}`; + if (sameLine) { + posInfo += ` (L${change.fromLine}:${change.fromCol}-${change.toCol})`; + } else { + posInfo += ` (L${change.fromLine}:${change.fromCol} to L${change.toLine}:${change.toCol})`; + } + + return ( +
+
+ Change {idx + 1}: {posInfo} +
+ {deleted > 0 &&
Deleted {deleted} chars
} + {inserted > 0 && ( +
+ Inserted: {change.insert} +
+ )} +
+ ); + })} +
+ ) : ( +
+ Changes: none +
+ )} + + {Object.keys(tr.annotations).length > 0 ? ( +
+ Annotations: + {Object.entries(tr.annotations).map(([key, value]) => ( +
+ {key}: {JSON.stringify(value)} +
+ ))} +
+ ) : ( +
+ Annotations: none +
+ )} + + {tr.effects.length > 0 ? ( +
+ Effects: {tr.effects.length} + {tr.blockMetadata ? ( +
+
blockMetadataEffect ({tr.blockMetadata.length} blocks)
+ {tr.blockMetadata.map((block, blockIdx) => ( +
+
Block {blockIdx + 1}:
+
+ {block.output !== null ? `Output: ${block.output.from}-${block.output.to}` : "Output: null"} +
+
+ Source: {block.source.from}-{block.source.to} +
+ {Object.keys(block.attributes).length > 0 && ( +
Attributes: {JSON.stringify(block.attributes)}
+ )} + {block.error &&
Error: true
} +
+ ))} +
+ ) : null} + {tr.effects.map((effect, idx) => ( +
+
+ + Effect {idx + 1} ({effect.type}):{" "} + + +
+
+ ))} +
+ ) : ( +
+ Effects: none +
+ )} + +
+ Selection: + {tr.selection.map((range, idx) => { + const isCursor = range.from === range.to; + return ( +
+ Range {idx + 1}: {isCursor ? `cursor at ${range.from}` : `${range.from}-${range.to}`} + {!isCursor && ` (anchor: ${range.anchor}, head: ${range.head})`} +
+ ); + })} +
+
+
+ ); +} diff --git a/test/components/TransactionViewer.tsx b/test/components/TransactionViewer.tsx new file mode 100644 index 0000000..a7c44a6 --- /dev/null +++ b/test/components/TransactionViewer.tsx @@ -0,0 +1,217 @@ +import {useState, useEffect, useRef, useMemo} from "react"; +import {ViewPlugin, ViewUpdate} from "@codemirror/view"; +import {Transaction as Tr} from "@codemirror/state"; +import {blockMetadataEffect} from "../../editor/blockMetadata.ts"; +import {SelectionGroupItem} from "./SelectionGroupItem.tsx"; +import type {TransactionData, TransactionGroup, TransactionViewerProps} from "./types.ts"; +import {TransactionItem} from "./TransactionItem.tsx"; + +// Maximum number of transactions to keep in history +const MAX_HISTORY = 100; + +function extractTransactionData(tr: Tr, index: number): TransactionData { + const data: TransactionData = { + index, + docChanged: tr.docChanged, + changes: [], + annotations: {}, + effects: [], + selection: tr.state.selection.ranges.map((r) => ({ + from: r.from, + to: r.to, + anchor: r.anchor, + head: r.head, + })), + scrollIntoView: tr.scrollIntoView, + timestamp: Date.now(), + blockMetadata: null, + }; + + // Extract changes + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + const fromLine = tr.startState.doc.lineAt(fromA); + const toLine = tr.startState.doc.lineAt(toA); + + data.changes.push({ + from: fromA, + to: toA, + fromLine: fromLine.number, + fromCol: fromA - fromLine.from, + toLine: toLine.number, + toCol: toA - toLine.from, + insert: inserted.toString(), + }); + }); + + // Extract annotations + const userEvent = tr.annotation(Tr.userEvent); + if (userEvent !== undefined) { + data.annotations.userEvent = userEvent; + } + + const remote = tr.annotation(Tr.remote); + if (remote !== undefined) { + data.annotations.remote = remote; + } + + const addToHistory = tr.annotation(Tr.addToHistory); + if (addToHistory !== undefined) { + data.annotations.addToHistory = addToHistory; + } + + for (const effect of tr.effects) { + if (effect.is(blockMetadataEffect)) { + data.blockMetadata = Array.from(effect.value); + } else { + data.effects.push({ + value: effect.value, + type: "StateEffect", + }); + } + } + + return data; +} + +export function TransactionViewer({onPluginCreate}: TransactionViewerProps) { + const [transactions, setTransactions] = useState([]); + const [autoScroll, setAutoScroll] = useState(true); + const [showEffects, setShowEffects] = useState(false); + const listRef = useRef(null); + const nextIndexRef = useRef(0); + + useEffect(() => { + const transactionsList: TransactionData[] = []; + const listeners = new Set<(transactions: TransactionData[]) => void>(); + + function notifyListeners() { + listeners.forEach((fn) => fn([...transactionsList])); + } + + const plugin = ViewPlugin.fromClass( + class { + constructor() { + notifyListeners(); + } + + update(update: ViewUpdate) { + update.transactions.forEach((tr: Tr) => { + const transactionData = extractTransactionData(tr, nextIndexRef.current++); + transactionsList.push(transactionData); + + if (transactionsList.length > MAX_HISTORY) { + transactionsList.shift(); + } + }); + + if (update.transactions.length > 0) { + notifyListeners(); + } + } + }, + ); + + listeners.add((newTransactions) => { + setTransactions(newTransactions); + }); + + onPluginCreate(plugin); + + return () => { + listeners.clear(); + }; + }, [onPluginCreate]); + + useEffect(() => { + if (autoScroll && listRef.current) { + listRef.current.scrollTop = 0; + } + }, [transactions, autoScroll]); + + const handleClear = () => { + setTransactions([]); + nextIndexRef.current = 0; + }; + + const filteredTransactions = useMemo(() => { + return showEffects ? transactions : transactions.filter((tr) => tr.effects.length === 0); + }, [transactions, showEffects]); + + const groupedTransactions = useMemo(() => { + const groups: TransactionGroup[] = []; + let currentGroup: TransactionGroup | null = null; + + for (let i = filteredTransactions.length - 1; i >= 0; i--) { + const tr = filteredTransactions[i]!; + const isSelection = tr.annotations.userEvent === "select" || tr.annotations.userEvent === "select.pointer"; + + if (isSelection) { + if (currentGroup && currentGroup.type === "selection") { + currentGroup.transactions!.push(tr); + } else { + currentGroup = { + type: "selection", + transactions: [tr], + }; + groups.push(currentGroup); + } + } else { + groups.push({ + type: "individual", + transaction: tr, + transactions: undefined, + }); + currentGroup = null; + } + } + + return groups; + }, [filteredTransactions]); + + return ( +
+
+

Transactions

+
+ + + +
+
+
+ {transactions.length === 0 ? ( +
No transactions yet
+ ) : ( + groupedTransactions.map((group, idx) => + group.type === "individual" ? ( + + ) : ( + + ), + ) + )} +
+
+ ); +} diff --git a/test/components/UserEvent.tsx b/test/components/UserEvent.tsx new file mode 100644 index 0000000..a2fc6b8 --- /dev/null +++ b/test/components/UserEvent.tsx @@ -0,0 +1,99 @@ +import { + ArrowDownUpIcon, + ClipboardPasteIcon, + CornerDownLeftIcon, + DeleteIcon, + KeyboardIcon, + LanguagesIcon, + ListPlusIcon, + MoveIcon, + RedoDotIcon, + ScissorsIcon, + SquareDashedMousePointerIcon, + SquareMousePointerIcon, + TextCursorInputIcon, + UndoDotIcon, + type LucideIcon, +} from "lucide-react"; +import {cn} from "../../app/cn.js"; + +export function UserEvent({userEvent}: {userEvent: string}) { + let Icon: LucideIcon; + let className: string = ""; + let text: string; + switch (userEvent) { + case "input": + Icon = CornerDownLeftIcon; + text = "Newline"; + break; + case "input.type": + Icon = KeyboardIcon; + text = "Input"; + break; + case "input.type.compose": + Icon = LanguagesIcon; + text = "Input"; + break; + case "input.paste": + Icon = ClipboardPasteIcon; + text = "Paste"; + break; + case "input.drop": + Icon = SquareMousePointerIcon; + text = "Drop"; + break; + case "input.copyline": + Icon = ListPlusIcon; + text = "Duplicate Line"; + break; + case "input.complete": + Icon = ListPlusIcon; + text = "Complete"; + break; + case "delete.selection": + Icon = TextCursorInputIcon; + text = "Delete Selection"; + break; + case "delete.forward": + Icon = DeleteIcon; + className = "rotate-180"; + text = "Delete"; + break; + case "delete.backward": + Icon = DeleteIcon; + text = "Backspace"; + break; + case "delete.cut": + Icon = ScissorsIcon; + text = "Cut"; + break; + case "move": + Icon = MoveIcon; + text = "Move (External)"; + break; + case "move.line": + Icon = ArrowDownUpIcon; + text = "Move Line"; + break; + case "move.drop": + Icon = SquareDashedMousePointerIcon; + text = "Move"; + break; + case "undo": + Icon = UndoDotIcon; + text = "Undo"; + break; + case "redo": + Icon = RedoDotIcon; + text = "Redo"; + break; + default: + return null; + } + return ( +
+ + {text} +
+ ); +} diff --git a/test/components/types.ts b/test/components/types.ts new file mode 100644 index 0000000..1e0a86c --- /dev/null +++ b/test/components/types.ts @@ -0,0 +1,57 @@ +import type {PluginValue, ViewPlugin} from "@codemirror/view"; +import type {BlockMetadata} from "../../editor/blocks/BlockMetadata.ts"; + +export interface BlockData { + name: string; + index: number; + sourceFrom: number; + sourceTo: number; + outputFrom: number | null; + outputTo: number | null; + hasError: boolean; + attributes: Record; +} + +export interface TransactionRange { + from: number; + to: number; + anchor: number; + head: number; +} + +export interface TransactionChange { + from: number; + to: number; + fromLine: number; + fromCol: number; + toLine: number; + toCol: number; + insert: string; +} + +export interface TransactionEffect { + value: unknown; + type: string; +} + +export interface TransactionData { + index: number; + docChanged: boolean; + changes: TransactionChange[]; + annotations: Record; + effects: TransactionEffect[]; + selection: TransactionRange[]; + scrollIntoView?: boolean; + timestamp: number; + blockMetadata: BlockMetadata[] | null; +} + +export interface TransactionGroup { + type: "individual" | "selection"; + transaction?: TransactionData; + transactions?: TransactionData[]; +} + +export interface TransactionViewerProps { + onPluginCreate: (plugin: ViewPlugin) => void; +} diff --git a/test/containers/heap.spec.ts b/test/containers/heap.spec.ts new file mode 100644 index 0000000..404223d --- /dev/null +++ b/test/containers/heap.spec.ts @@ -0,0 +1,286 @@ +import {it, expect, describe} from "vitest"; +import {MaxHeap} from "../../lib/containers/heap.js"; + +describe("MaxHeap", () => { + describe("basic operations", () => { + it("should insert and extract values in max order", () => { + const heap = new MaxHeap((x) => x); + + heap.insert(5); + heap.insert(3); + heap.insert(8); + heap.insert(1); + heap.insert(10); + + expect(heap.size).toBe(5); + expect(heap.extractMax()).toBe(10); + expect(heap.extractMax()).toBe(8); + expect(heap.extractMax()).toBe(5); + expect(heap.extractMax()).toBe(3); + expect(heap.extractMax()).toBe(1); + expect(heap.isEmpty).toBe(true); + }); + + it("should peek without removing", () => { + const heap = new MaxHeap((x) => x); + + heap.insert(5); + heap.insert(10); + heap.insert(3); + + expect(heap.peek).toBe(10); + expect(heap.peekWeight()).toBe(10); + expect(heap.size).toBe(3); + + expect(heap.extractMax()).toBe(10); + expect(heap.peek).toBe(5); + expect(heap.size).toBe(2); + }); + + it("should handle empty heap", () => { + const heap = new MaxHeap((x) => x); + + expect(heap.isEmpty).toBe(true); + expect(heap.size).toBe(0); + expect(heap.peek).toBeUndefined(); + expect(heap.peekWeight()).toBeUndefined(); + expect(heap.extractMax()).toBeUndefined(); + }); + + it("should check if value exists", () => { + const heap = new MaxHeap((x) => x); + + heap.insert(5); + heap.insert(10); + + expect(heap.has(5)).toBe(true); + expect(heap.has(10)).toBe(true); + expect(heap.has(3)).toBe(false); + + heap.extractMax(); + expect(heap.has(10)).toBe(false); + expect(heap.has(5)).toBe(true); + }); + + it("should clear the heap", () => { + const heap = new MaxHeap((x) => x); + + heap.insert(5); + heap.insert(10); + heap.insert(3); + + expect(heap.size).toBe(3); + + heap.clear(); + + expect(heap.isEmpty).toBe(true); + expect(heap.size).toBe(0); + expect(heap.has(5)).toBe(false); + }); + }); + + describe("update existing values", () => { + it("should update value with increased weight and percolate up", () => { + const weights = new Map([ + ["a", 5], + ["b", 10], + ["c", 3], + ]); + + const heap = new MaxHeap((x) => weights.get(x)!); + + heap.insert("a"); + heap.insert("b"); + heap.insert("c"); + + expect(heap.peek).toBe("b"); + + // Update "a" to have weight 15 + weights.set("a", 15); + heap.insert("a"); + + expect(heap.peek).toBe("a"); + expect(heap.peekWeight()).toBe(15); + }); + + it("should update value with decreased weight and percolate down", () => { + const weights = new Map([ + ["a", 5], + ["b", 10], + ["c", 3], + ]); + + const heap = new MaxHeap((x) => weights.get(x)!); + + heap.insert("a"); + heap.insert("b"); + heap.insert("c"); + + expect(heap.peek).toBe("b"); + + // Update "b" to have weight 1 + weights.set("b", 1); + heap.insert("b"); + + expect(heap.peek).toBe("a"); + expect(heap.peekWeight()).toBe(5); + + heap.extractMax(); + expect(heap.peek).toBe("c"); + expect(heap.peekWeight()).toBe(3); + }); + + it("should not change heap when weight stays the same", () => { + const weights = new Map([ + ["a", 5], + ["b", 10], + ]); + + const heap = new MaxHeap((x) => weights.get(x)!); + + heap.insert("a"); + heap.insert("b"); + + expect(heap.size).toBe(2); + expect(heap.peek).toBe("b"); + + // Re-insert "b" with same weight + heap.insert("b"); + + expect(heap.size).toBe(2); + expect(heap.peek).toBe("b"); + }); + + it("should handle multiple updates on same value", () => { + const weights = new Map([["a", 5]]); + + const heap = new MaxHeap((x) => weights.get(x)!); + + heap.insert("a"); + expect(heap.peekWeight()).toBe(5); + + weights.set("a", 10); + heap.insert("a"); + expect(heap.peekWeight()).toBe(10); + + weights.set("a", 3); + heap.insert("a"); + expect(heap.peekWeight()).toBe(3); + + weights.set("a", 7); + heap.insert("a"); + expect(heap.peekWeight()).toBe(7); + + expect(heap.size).toBe(1); + }); + }); + + describe("custom comparator", () => { + it("should use custom comparator for min heap behavior", () => { + // Reverse comparator for min heap + const heap = new MaxHeap( + (x) => x, + (a, b) => b - a, + ); + + heap.insert(5); + heap.insert(3); + heap.insert(8); + heap.insert(1); + + expect(heap.extractMax()).toBe(1); + expect(heap.extractMax()).toBe(3); + expect(heap.extractMax()).toBe(5); + expect(heap.extractMax()).toBe(8); + }); + + it("should work with objects and custom key function", () => { + interface Task { + name: string; + priority: number; + } + + const heap = new MaxHeap((task) => task.priority); + + heap.insert({name: "Low", priority: 1}); + heap.insert({name: "High", priority: 10}); + heap.insert({name: "Medium", priority: 5}); + + expect(heap.extractMax()?.name).toBe("High"); + expect(heap.extractMax()?.name).toBe("Medium"); + expect(heap.extractMax()?.name).toBe("Low"); + }); + }); + + describe("edge cases", () => { + it("should handle single element", () => { + const heap = new MaxHeap((x) => x); + + heap.insert(42); + expect(heap.size).toBe(1); + expect(heap.peek).toBe(42); + expect(heap.extractMax()).toBe(42); + expect(heap.isEmpty).toBe(true); + }); + + it("should handle duplicate weights", () => { + const heap = new MaxHeap((x) => Math.floor(x / 10)); + + heap.insert(15); + heap.insert(12); + heap.insert(18); + + // All have weight 1 + expect(heap.size).toBe(3); + expect(heap.extractMax()).toBe(15); // First inserted with weight 1 + }); + + it("should handle large number of elements", () => { + const heap = new MaxHeap((x) => x); + const values = Array.from({length: 100}, (_, i) => i); + + // Insert in random order + const shuffled = [...values].sort(() => Math.random() - 0.5); + shuffled.forEach((v) => heap.insert(v)); + + expect(heap.size).toBe(100); + + // Extract all and verify descending order + const extracted = []; + while (!heap.isEmpty) { + extracted.push(heap.extractMax()!); + } + + expect(extracted).toEqual(values.reverse()); + }); + + it("should maintain heap property after multiple operations", () => { + const weights = new Map(); + const heap = new MaxHeap((x) => weights.get(x)!); + + // Insert values + weights.set("a", 5); + weights.set("b", 10); + weights.set("c", 3); + weights.set("d", 7); + + heap.insert("a"); + heap.insert("b"); + heap.insert("c"); + heap.insert("d"); + + // Update some values + weights.set("c", 15); + heap.insert("c"); + + weights.set("b", 2); + heap.insert("b"); + + // Extract and verify order + expect(heap.extractMax()).toBe("c"); // 15 + expect(heap.extractMax()).toBe("d"); // 7 + expect(heap.extractMax()).toBe("a"); // 5 + expect(heap.extractMax()).toBe("b"); // 2 + }); + }); +}); diff --git a/test/index.css b/test/index.css index 99016db..1cf70b0 100644 --- a/test/index.css +++ b/test/index.css @@ -1 +1,41 @@ @import "../editor/index.css"; + +body { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + font-size: 16px; + + margin: 0; + padding: 0.5rem; + box-sizing: border-box; + display: flex; + flex-direction: row; + gap: 0.5rem; + height: 100vh; + width: 100vw; + + main { + flex: 1; + background: #55550003; + border: 1px solid #19130029; + padding: 0.75rem; + } + + aside { + width: 25rem; + padding: 0.75rem; + background: #25250007; + border: 1px solid #19130029; + overflow-y: auto; + } +} diff --git a/test/index.html b/test/index.html index 87fca19..3de6502 100644 --- a/test/index.html +++ b/test/index.html @@ -4,10 +4,9 @@ Recho (Test) -
- + diff --git a/test/main.js b/test/main.js index a350f18..8bb045a 100644 --- a/test/main.js +++ b/test/main.js @@ -1,5 +1,15 @@ import * as jsTests from "./js/index.js"; import {createEditor} from "../editor/index.js"; +import {createTransactionViewer} from "./transactionViewer.js"; + +// The main and side panels +const mainPanel = document.createElement("main"); +mainPanel.id = "main-panel"; +document.body.append(mainPanel); + +const sidePanels = document.createElement("aside"); +sidePanels.id = "side-panels"; +document.body.append(sidePanels); // Select const select = createSelect(() => { @@ -9,26 +19,40 @@ const select = createSelect(() => { }); const options = Object.keys(jsTests).map(createOption); select.append(...options); -document.body.append(select); +mainPanel.append(select); const container = document.createElement("div"); container.id = "container"; -document.body.append(container); +mainPanel.append(container); // Init app name. const initialValue = new URL(location).searchParams.get("name"); if (jsTests[initialValue]) select.value = initialValue; let preEditor = null; +let transactionViewer = null; render(); async function render() { container.innerHTML = ""; if (preEditor) preEditor.destroy(); + if (transactionViewer) transactionViewer.destroy(); + + // Clear and reset side panels + sidePanels.innerHTML = ""; + + // Create transaction viewer + transactionViewer = createTransactionViewer(sidePanels); + const editorContainer = document.createElement("div"); const code = jsTests[select.value]; - const editor = (preEditor = createEditor(editorContainer, {code})); + const editor = (preEditor = createEditor(editorContainer, { + code, + extensions: [transactionViewer.plugin], + })); editor.run(); + preEditor = editor; + const runButton = document.createElement("button"); runButton.textContent = "Run"; runButton.onclick = () => editor.run(); diff --git a/test/main.tsx b/test/main.tsx new file mode 100644 index 0000000..31df8df --- /dev/null +++ b/test/main.tsx @@ -0,0 +1,18 @@ +import {StrictMode} from "react"; +import {createRoot} from "react-dom/client"; +import {Provider as JotaiProvider} from "jotai"; +import {App} from "./components/App.tsx"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("Root element not found"); +} + +createRoot(root).render( + + + + + , +); diff --git a/test/snapshpts.spec.js b/test/snapshpts.spec.js index 1157c51..dd605d4 100644 --- a/test/snapshpts.spec.js +++ b/test/snapshpts.spec.js @@ -12,7 +12,7 @@ describe("snapshots", () => { console.error = () => {}; try { const runtime = createRuntime(state.doc.toString()); - runtime.onChanges((changes) => (state = state.update({changes}).state)); + runtime.onChanges((specs) => (state = state.update(specs).state)); runtime.run(); await new Promise((resolve) => setTimeout(resolve, 100)); } catch (error) {} diff --git a/test/store.ts b/test/store.ts new file mode 100644 index 0000000..276f1a1 --- /dev/null +++ b/test/store.ts @@ -0,0 +1,58 @@ +import {atom} from "jotai"; + +// Types +export interface Transaction { + time: number; + docChanged: boolean; + selection: {from: number; to: number} | null; + effects: string[]; + annotations: string[]; +} + +export interface TestSample { + name: string; + code: string; +} + +// Atoms +export const selectedTestAtom = atom("helloWorld"); +export const transactionHistoryAtom = atom([]); +export const autoScrollAtom = atom(true); + +// Factory functions for state operations +export function createTransaction(data: { + time: number; + docChanged: boolean; + selection: {from: number; to: number} | null; + effects: string[]; + annotations: string[]; +}): Transaction { + return { + time: data.time, + docChanged: data.docChanged, + selection: data.selection, + effects: data.effects, + annotations: data.annotations, + }; +} + +export function addTransaction(history: Transaction[], transaction: Transaction): Transaction[] { + const newHistory = [...history, transaction]; + // Keep max 100 transactions + if (newHistory.length > 100) { + return newHistory.slice(-100); + } + return newHistory; +} + +export function clearTransactionHistory(): Transaction[] { + return []; +} + +export function getTestSampleName(key: string): string { + // Convert camelCase to Title Case with spaces + return key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (str) => str.toUpperCase()) + .trim(); +} diff --git a/test/styles.css b/test/styles.css new file mode 100644 index 0000000..37de1dd --- /dev/null +++ b/test/styles.css @@ -0,0 +1,41 @@ +@import "tailwindcss"; +@import "../editor/index.css"; +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/spline-sans-mono"; + +@theme { + --default-font-family: "Inter Variable"; + --font-mono: "Spline Sans Mono Variable"; +} + +button { + cursor: pointer; +} + +.cm-gutters { + background-color: white !important; + border-right: none !important; + padding-left: 8px !important; +} + +.cm-editor.cm-focused { + outline: none !important; +} + +/* Details/Summary styling for transaction viewer */ +details summary { + list-style: none; +} + +details summary::-webkit-details-marker { + display: none; +} + +details summary::marker { + display: none; +} + +details[open] > summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} diff --git a/test/transactionViewer.css b/test/transactionViewer.css new file mode 100644 index 0000000..23a9443 --- /dev/null +++ b/test/transactionViewer.css @@ -0,0 +1,305 @@ +/* Transaction Viewer Styles */ +.transaction-viewer { + font-size: 14px; + + display: flex; + flex-direction: column; + height: 100%; + + h3 { + margin: 0 0 0.75rem 0; + font-size: 1.1em; + font-weight: 600; + } +} + +.transaction-controls { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #19130029; + + button { + padding: 0.25rem 0.5rem; + font-size: 12px; + border: 1px solid #19130029; + background: white; + border-radius: 3px; + cursor: pointer; + + &:hover { + background: #f6f6f6; + } + } + + label { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 12px; + cursor: pointer; + } +} + +.transaction-list-scrollable { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.transaction-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.no-transactions { + color: #666; + font-style: italic; + text-align: center; + padding: 2rem 0; +} + +.transaction-item { + border: 1px solid #d0d7de; + border-radius: 6px; + background: white; + padding: 0; + transition: all 0.2s; + + &[open] { + border-color: #8b949e; + } + + &.transaction-group { + border-color: #9a6dd7; + background: #faf8ff; + + &[open] { + border-color: #7c3aed; + } + + summary { + background: #f3e8ff; + + &:hover { + background: #e9d5ff; + } + } + } + + summary { + padding: 0.5rem; + cursor: pointer; + font-weight: 500; + font-family: ui-monospace, monospace; + font-size: 12px; + user-select: none; + background: #f6f8fa; + border-radius: 5px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + + &:hover { + background: #eaeef2; + } + } + + .tr-summary-left { + flex: 1; + min-width: 0; + } + + .tr-summary-right { + flex-shrink: 0; + color: #6e7781; + font-size: 11px; + } + + &.user-transaction summary { + background: #dff6dd; + + &:hover { + background: #c8f0c5; + } + } + + &.remote-transaction summary { + background: #fff8c5; + + &:hover { + background: #fff3b0; + } + } + + &.doc-changed summary { + font-weight: 600; + } +} + +.tr-details { + padding: 0.75rem; + font-size: 12px; + line-height: 1.5; +} + +.tr-field { + margin-bottom: 0.5rem; + + strong { + color: #1f2328; + } +} + +.tr-change { + margin-left: 1rem; + margin-bottom: 0.5rem; + padding: 0.5rem; + background: #f6f8fa; + border-radius: 4px; + font-size: 11px; + + code { + background: #ffffff; + padding: 0.125rem 0.25rem; + border-radius: 3px; + font-family: ui-monospace, monospace; + border: 1px solid #d0d7de; + } +} + +.deleted { + color: #cf222e; + margin-top: 0.25rem; +} + +.inserted { + color: #1a7f37; + margin-top: 0.25rem; +} + +.tr-annotation { + margin-left: 1rem; + padding: 0.25rem 0.5rem; + background: #dff6ff; + border-left: 3px solid #0969da; + font-family: ui-monospace, monospace; + font-size: 11px; + margin-bottom: 0.25rem; + border-radius: 3px; +} + +.tr-effect { + margin-left: 1rem; + padding: 0.25rem 0.5rem; + background: #fbefff; + border-left: 3px solid #8250df; + font-family: ui-monospace, monospace; + font-size: 11px; + margin-bottom: 0.25rem; + border-radius: 3px; +} + +.tr-selection { + margin-left: 1rem; + padding: 0.25rem 0.5rem; + background: #fff8c5; + border-left: 3px solid #bf8700; + font-family: ui-monospace, monospace; + font-size: 11px; + margin-bottom: 0.25rem; + border-radius: 3px; +} + +.tr-property { + margin-left: 1rem; + padding: 0.25rem 0.5rem; + background: #f6f8fa; + border-left: 3px solid #6e7781; + font-family: ui-monospace, monospace; + font-size: 11px; + margin-bottom: 0.25rem; + border-radius: 3px; +} + +.tr-effect-block-metadata { + background: #f3e8ff; + border-left-color: #a855f7; + padding: 0.5rem; +} + +.tr-effect-title { + font-weight: 600; + margin-bottom: 0.5rem; + color: #7c3aed; +} + +.tr-block-metadata { + margin-left: 0.5rem; + margin-top: 0.5rem; + padding: 0.5rem; + background: white; + border: 1px solid #e9d5ff; + border-radius: 3px; +} + +.tr-block-header { + font-weight: 600; + margin-bottom: 0.25rem; + color: #6b21a8; +} + +.tr-block-detail { + font-size: 11px; + margin-left: 0.5rem; + margin-bottom: 0.125rem; + color: #4b5563; +} + +.tr-block-error { + color: #dc2626; + font-weight: 600; +} + +.tr-grouped-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.tr-grouped-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + background: white; + border: 1px solid #e9d5ff; + border-radius: 4px; + font-size: 11px; + font-family: ui-monospace, monospace; +} + +.tr-grouped-index { + font-weight: 600; + color: #7c3aed; + min-width: 2.5rem; +} + +.tr-grouped-selection { + flex: 1; + color: #4b5563; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tr-grouped-time { + color: #9ca3af; + font-size: 10px; + flex-shrink: 0; +} diff --git a/test/transactionViewer.js b/test/transactionViewer.js new file mode 100644 index 0000000..b7447e7 --- /dev/null +++ b/test/transactionViewer.js @@ -0,0 +1,496 @@ +import {ViewPlugin} from "@codemirror/view"; +import {Transaction} from "@codemirror/state"; +import {blockMetadataEffect} from "../editor/blockMetadata.ts"; + +// Maximum number of transactions to keep in history +const MAX_HISTORY = 100; + +/** + * Creates a transaction tracker plugin and UI viewer + */ +export function createTransactionViewer(container) { + const transactions = []; + const listeners = new Set(); + let nextIndex = 0; // Continuous index counter + + // Notify all listeners when transactions update + function notifyListeners() { + listeners.forEach((fn) => fn(transactions)); + } + + // Create the ViewPlugin to track transactions + const plugin = ViewPlugin.fromClass( + class { + constructor(view) { + // Capture initial state as transaction 0 + const initialTr = { + index: nextIndex++, + docChanged: false, + changes: [], + annotations: {}, + effects: [], + selection: view.state.selection.ranges.map((r) => ({ + from: r.from, + to: r.to, + anchor: r.anchor, + head: r.head, + })), + timestamp: Date.now(), + }; + transactions.push(initialTr); + notifyListeners(); + } + + update(update) { + // Process each transaction in the update + update.transactions.forEach((tr, idx) => { + const transactionData = extractTransactionData(tr, nextIndex++); + + // Add to history + transactions.push(transactionData); + + // Keep only the last MAX_HISTORY transactions + if (transactions.length > MAX_HISTORY) { + transactions.shift(); + } + }); + + if (update.transactions.length > 0) { + notifyListeners(); + } + } + }, + ); + + // Extract data from a transaction + function extractTransactionData(tr, index) { + const data = { + index, + docChanged: tr.docChanged, + changes: [], + annotations: {}, + effects: [], + selection: tr.state.selection.ranges.map((r) => ({ + from: r.from, + to: r.to, + anchor: r.anchor, + head: r.head, + })), + scrollIntoView: tr.scrollIntoView, + filter: tr.filter, + sequential: tr.sequential, + timestamp: Date.now(), + }; + + // Extract changes with line/column information + // Use startState for from/to positions (before the change) + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + const fromLine = tr.startState.doc.lineAt(fromA); + const toLine = tr.startState.doc.lineAt(toA); + + data.changes.push({ + from: fromA, + to: toA, + fromLine: fromLine.number, + fromCol: fromA - fromLine.from, + toLine: toLine.number, + toCol: toA - toLine.from, + insert: inserted.toString(), + }); + }); + + // Extract annotations + const userEvent = tr.annotation(Transaction.userEvent); + if (userEvent !== undefined) { + data.annotations.userEvent = userEvent; + } + + const remote = tr.annotation(Transaction.remote); + if (remote !== undefined) { + data.annotations.remote = remote; + } + + // Check for other common annotations + const addToHistory = tr.annotation(Transaction.addToHistory); + if (addToHistory !== undefined) { + data.annotations.addToHistory = addToHistory; + } + + // Extract effects + for (const effect of tr.effects) { + const effectData = { + value: effect.value, + type: "StateEffect", + }; + + // Check if this is a blockMetadataEffect + if (effect.is(blockMetadataEffect)) { + effectData.type = "blockMetadataEffect"; + effectData.blockMetadata = effect.value; + } + + data.effects.push(effectData); + } + + return data; + } + + // Create the UI + const viewerElement = document.createElement("div"); + viewerElement.className = "transaction-viewer"; + viewerElement.innerHTML = ` +

Transactions

+
+ + +
+
+
+
+ `; + + const listElement = viewerElement.querySelector(".transaction-list"); + const clearButton = viewerElement.querySelector("#clear-transactions"); + const autoScrollCheckbox = viewerElement.querySelector("#auto-scroll"); + + clearButton.onclick = () => { + transactions.length = 0; + nextIndex = 0; // Reset the index counter + renderTransactions(); + }; + + function renderTransactions() { + listElement.innerHTML = ""; + + if (transactions.length === 0) { + listElement.innerHTML = '
No transactions yet
'; + return; + } + + // Group transactions - group consecutive selection transactions + const groups = []; + let currentGroup = null; + + // Process in reverse order (newest first) + for (let i = transactions.length - 1; i >= 0; i--) { + const tr = transactions[i]; + const isSelection = tr.annotations.userEvent === "select" || tr.annotations.userEvent === "select.pointer"; + + if (isSelection) { + if (currentGroup && currentGroup.type === "selection") { + // Add to existing selection group + currentGroup.transactions.push(tr); + } else { + // Start a new selection group + currentGroup = { + type: "selection", + transactions: [tr], + }; + groups.push(currentGroup); + } + } else { + // Non-selection transaction - add as individual + groups.push({ + type: "individual", + transaction: tr, + }); + currentGroup = null; + } + } + + // Render groups + for (const group of groups) { + if (group.type === "individual") { + const item = createTransactionItem(group.transaction); + listElement.appendChild(item); + } else { + const groupItem = createSelectionGroupItem(group.transactions); + listElement.appendChild(groupItem); + } + } + + // Auto-scroll to top (latest transaction) + if (autoScrollCheckbox.checked) { + listElement.scrollTop = 0; + } + } + + /** + * + * @param {import("@codemirror/state").TransactionSpec} tr + * @returns + */ + function createSelectionGroupItem(transactions) { + const item = document.createElement("details"); + item.className = "transaction-item transaction-group"; + + const count = transactions.length; + const firstTr = transactions[0]; + const lastTr = transactions[transactions.length - 1]; + + // Format timestamps + const firstTime = new Date(firstTr.timestamp); + const lastTime = new Date(lastTr.timestamp); + const firstTimeStr = + firstTime.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + "." + + firstTime.getMilliseconds().toString().padStart(3, "0"); + const lastTimeStr = + lastTime.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + "." + + lastTime.getMilliseconds().toString().padStart(3, "0"); + + const summaryLeft = `#${lastTr.index}-${firstTr.index} [select] (${count} transactions)`; + const summaryRight = `${lastTimeStr} - ${firstTimeStr}`; + + // Create list of individual transactions + const transactionsList = transactions + .map((tr) => { + const time = new Date(tr.timestamp); + const timeStr = + time.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + "." + + time.getMilliseconds().toString().padStart(3, "0"); + + const selectionInfo = tr.selection + .map((range, idx) => { + const isCursor = range.from === range.to; + return isCursor ? `cursor at ${range.from}` : `${range.from}-${range.to}`; + }) + .join(", "); + + return ` +
+ #${tr.index} + ${selectionInfo} + ${timeStr} +
+ `; + }) + .join(""); + + item.innerHTML = ` + + ${summaryLeft} + ${summaryRight} + +
+
+ ${transactionsList} +
+
+ `; + + return item; + } + + function createTransactionItem(tr) { + const item = document.createElement("details"); + item.className = "transaction-item"; + + // Add classes based on transaction type + if (tr.annotations.userEvent) { + item.classList.add("user-transaction"); + } + if (tr.annotations.remote) { + item.classList.add("remote-transaction"); + } + if (tr.docChanged) { + item.classList.add("doc-changed"); + } + + // Format timestamp as HH:MM:SS.mmm + const time = new Date(tr.timestamp); + const timeStr = + time.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + + "." + + time.getMilliseconds().toString().padStart(3, "0"); + + let summaryLeft = `#${tr.index}`; + if (tr.annotations.userEvent) { + summaryLeft += ` [${tr.annotations.userEvent}]`; + } else if (tr.annotations.remote) { + summaryLeft += ` [remote: ${JSON.stringify(tr.annotations.remote)}]`; + } + if (tr.docChanged) { + summaryLeft += ` 📝`; + } + + const details = []; + + // Document changed + details.push(`
Doc Changed: ${tr.docChanged}
`); + + // Changes + if (tr.changes.length > 0) { + details.push(`
Changes:
`); + tr.changes.forEach((change, idx) => { + const deleted = change.to - change.from; + const inserted = change.insert.length; + const samePos = change.from === change.to; + const sameLine = change.fromLine === change.toLine; + + let posInfo = `pos ${change.from}-${change.to}`; + if (sameLine) { + posInfo += ` (L${change.fromLine}:${change.fromCol}-${change.toCol})`; + } else { + posInfo += ` (L${change.fromLine}:${change.fromCol} to L${change.toLine}:${change.toCol})`; + } + + details.push(` +
+
Change ${idx + 1}: ${posInfo}
+ ${deleted > 0 ? `
Deleted ${deleted} chars
` : ""} + ${inserted > 0 ? `
Inserted: ${escapeHtml(change.insert)}
` : ""} +
+ `); + }); + } else { + details.push(`
Changes: none
`); + } + + // Annotations + if (Object.keys(tr.annotations).length > 0) { + details.push(`
Annotations:
`); + for (const [key, value] of Object.entries(tr.annotations)) { + details.push(`
${key}: ${JSON.stringify(value)}
`); + } + } else { + details.push(`
Annotations: none
`); + } + + // Effects + if (Array.isArray(tr.effects) && tr.effects.length > 0) { + details.push(`
Effects: ${tr.effects.length}
`); + tr.effects.forEach((effect, idx) => { + if (effect.type === "blockMetadataEffect" && effect.blockMetadata) { + // Special handling for blockMetadataEffect + details.push(` + + `); + } else { + // Generic effect display + details.push(` +
+ Effect ${idx + 1} (${effect.type}): ${JSON.stringify(effect.value).substring(0, 100)} +
+ `); + } + }); + } else { + details.push(`
Effects: none
`); + } + + // Selection + details.push(`
Selection:
`); + tr.selection.forEach((range, idx) => { + const isCursor = range.from === range.to; + details.push(` +
+ Range ${idx + 1}: ${isCursor ? `cursor at ${range.from}` : `${range.from}-${range.to}`} + ${!isCursor ? `(anchor: ${range.anchor}, head: ${range.head})` : ""} +
+ `); + }); + + // Additional transaction properties + const additionalProps = []; + if (tr.scrollIntoView !== undefined) { + additionalProps.push(`scrollIntoView: ${tr.scrollIntoView}`); + } + if (tr.filter !== undefined) { + additionalProps.push(`filter: ${tr.filter}`); + } + if (tr.sequential !== undefined) { + additionalProps.push(`sequential: ${tr.sequential}`); + } + + if (additionalProps.length > 0) { + details.push(`
Other Properties:
`); + additionalProps.forEach((prop) => { + details.push(`
${prop}
`); + }); + } + + item.innerHTML = ` + + ${summaryLeft} + ${timeStr} + +
+ ${details.join("")} +
+ `; + + return item; + } + + function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + // Listen for transaction updates + listeners.add(renderTransactions); + + // Initial render + renderTransactions(); + + // Append to container + container.appendChild(viewerElement); + + return { + plugin, + destroy: () => { + listeners.clear(); + viewerElement.remove(); + }, + }; +} diff --git a/test/types/css.d.ts b/test/types/css.d.ts new file mode 100644 index 0000000..ef6d741 --- /dev/null +++ b/test/types/css.d.ts @@ -0,0 +1 @@ +declare module "*.css" {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6df60f3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,52 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + // "rootDir": "./src", + // "outDir": "./dist", + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": [], + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + // For nodejs: + "lib": ["esnext", "DOM"], + // "types": ["node"], + // and npm install -D @types/node + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + // Stricter Typechecking Options + // "noUncheckedIndexedAccess": true, + // "exactOptionalPropertyTypes": true, + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // Recommended Options + "strict": true, + "jsx": "preserve", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + "allowJs": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/vite.config.js b/vite.config.js index cb4a843..1abada3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,9 @@ import {defineConfig} from "vite"; +import react from "@vitejs/plugin-react"; export default defineConfig({ root: "test", + plugins: [react()], test: { environment: "jsdom", },