sourcebot/packages/web/src/features/chat/components/chatThread/codeFoldingExtension.ts

505 lines
17 KiB
TypeScript

import { Extension, StateField, StateEffect, Transaction, Range as CodeMirrorRange, EditorState } from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
WidgetType
} from "@codemirror/view";
import { FileReference } from "../../types";
import React, { CSSProperties } from "react";
import { createRoot } from "react-dom/client";
import { CodeFoldingExpandButton } from "./codeFoldingExpandButton";
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
interface Range {
startLine: number;
endLine: number;
}
interface HiddenRegion {
startLine: number;
endLine: number;
canExpandUp: boolean;
canExpandDown: boolean;
}
export interface FoldingState {
visibleRanges: Range[];
hiddenRegions: HiddenRegion[];
totalLines: number;
references: FileReference[];
padding: number;
}
// State effects for updating folding state
export const updateReferencesEffect = StateEffect.define<FileReference[]>();
export const expandRegionEffect = StateEffect.define<{
regionIndex: number;
direction: 'up' | 'down';
linesToExpand: number;
}>();
// Range calculation utilities
export const calculateVisibleRanges = (
references: FileReference[],
totalLines: number,
padding: number = 3
): Range[] => {
// Extract ranges from references that have them
const ranges: Range[] = references
.filter(ref => ref.range !== undefined)
.map(ref => ({
startLine: Math.max(1, ref.range!.startLine - padding),
endLine: Math.min(totalLines, ref.range!.endLine + padding),
}));
// If no ranges, show everything
if (ranges.length === 0) {
return [{ startLine: 1, endLine: totalLines }];
}
// Sort ranges by start line
ranges.sort((a, b) => a.startLine - b.startLine);
// Merge overlapping ranges
const mergedRanges: Range[] = [];
let currentRange = ranges[0];
for (let i = 1; i < ranges.length; i++) {
const nextRange = ranges[i];
// Check if ranges overlap or are adjacent
if (currentRange.endLine >= nextRange.startLine - 1) {
// Merge ranges
currentRange.endLine = Math.max(currentRange.endLine, nextRange.endLine);
} else {
// No overlap, add current range and start new one
mergedRanges.push(currentRange);
currentRange = nextRange;
}
}
// Add the last range
mergedRanges.push(currentRange);
return mergedRanges;
};
export const calculateHiddenRegions = (
visibleRanges: Range[],
totalLines: number
): HiddenRegion[] => {
const hiddenRegions: HiddenRegion[] = [];
// Hidden region before first visible range
if (visibleRanges.length > 0 && visibleRanges[0].startLine > 1) {
hiddenRegions.push({
startLine: 1,
endLine: visibleRanges[0].startLine - 1,
canExpandUp: true, // Can expand toward start of file
canExpandDown: false, // Can't expand toward visible content
});
}
// Hidden regions between visible ranges
for (let i = 0; i < visibleRanges.length - 1; i++) {
const currentRange = visibleRanges[i];
const nextRange = visibleRanges[i + 1];
if (currentRange.endLine + 1 < nextRange.startLine) {
hiddenRegions.push({
startLine: currentRange.endLine + 1,
endLine: nextRange.startLine - 1,
canExpandUp: true,
canExpandDown: true,
});
}
}
// Hidden region after last visible range
if (visibleRanges.length > 0) {
const lastRange = visibleRanges[visibleRanges.length - 1];
if (lastRange.endLine < totalLines) {
hiddenRegions.push({
startLine: lastRange.endLine + 1,
endLine: totalLines,
canExpandUp: false, // Can't expand toward visible content
canExpandDown: true, // Can expand toward end of file
});
}
}
return hiddenRegions;
};
export const createFoldingState = (
references: FileReference[],
totalLines: number,
padding: number = 3
): FoldingState => {
const visibleRanges = calculateVisibleRanges(references, totalLines, padding);
const hiddenRegions = calculateHiddenRegions(visibleRanges, totalLines);
return {
visibleRanges,
hiddenRegions,
totalLines,
references,
padding,
};
};
// State field management is now handled inside createCodeFoldingExtension
// Helper function to recalculate folding state
const recalculateFoldingState = (state: FoldingState): FoldingState => {
const visibleRanges = calculateVisibleRanges(state.references, state.totalLines, state.padding);
const hiddenRegions = calculateHiddenRegions(visibleRanges, state.totalLines);
return {
...state,
visibleRanges,
hiddenRegions,
};
};
// Helper function to expand a region
const expandRegionInternal = (
currentState: FoldingState,
hiddenRegionIndex: number,
direction: 'up' | 'down',
linesToExpand: number = 20
): FoldingState => {
const hiddenRegion = currentState.hiddenRegions[hiddenRegionIndex];
if (!hiddenRegion) return currentState;
const newVisibleRanges = [...currentState.visibleRanges];
if (direction === 'up' && hiddenRegion.canExpandUp) {
const startLine = Math.max(hiddenRegion.startLine, hiddenRegion.endLine - linesToExpand + 1);
newVisibleRanges.push({
startLine,
endLine: hiddenRegion.endLine,
});
} else if (direction === 'down' && hiddenRegion.canExpandDown) {
const endLine = Math.min(hiddenRegion.endLine, hiddenRegion.startLine + linesToExpand - 1);
newVisibleRanges.push({
startLine: hiddenRegion.startLine,
endLine,
});
}
// Sort and merge overlapping ranges
const sortedRanges = newVisibleRanges.sort((a, b) => a.startLine - b.startLine);
const mergedRanges = mergeOverlappingRanges(sortedRanges);
const newHiddenRegions = calculateHiddenRegions(mergedRanges, currentState.totalLines);
return {
...currentState,
visibleRanges: mergedRanges,
hiddenRegions: newHiddenRegions,
};
};
const mergeOverlappingRanges = (ranges: Range[]): Range[] => {
if (ranges.length === 0) return [];
// Sort ranges by start line
const sortedRanges = [...ranges].sort((a, b) => a.startLine - b.startLine);
const merged: Range[] = [];
let currentRange = sortedRanges[0];
for (let i = 1; i < sortedRanges.length; i++) {
const nextRange = sortedRanges[i];
// Check if ranges overlap or are adjacent
if (currentRange.endLine >= nextRange.startLine - 1) {
currentRange.endLine = Math.max(currentRange.endLine, nextRange.endLine);
} else {
merged.push(currentRange);
currentRange = nextRange;
}
}
merged.push(currentRange);
return merged;
};
// Action creators for dispatching state updates
export const updateReferences = (references: FileReference[]) => {
return {
effects: [updateReferencesEffect.of(references)],
};
};
export const expandRegion = (regionIndex: number, direction: 'up' | 'down', linesToExpand: number = 20) => {
return {
effects: [expandRegionEffect.of({ regionIndex, direction, linesToExpand })],
};
};
// Widget for expand buttons
class CodeFoldingExpandButtonWidget extends WidgetType {
constructor(
private regionIndex: number,
private direction: 'up' | 'down',
private canExpandUp: boolean,
private canExpandDown: boolean,
private hiddenLineCount: number
) {
super();
}
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-code-folding-expand-container';
// Create React root and render component
const root = createRoot(container);
root.render(
React.createElement(CodeFoldingExpandButton, {
hiddenLineCount: this.hiddenLineCount,
canExpandUp: this.canExpandUp,
canExpandDown: this.canExpandDown,
onExpand: (direction) => {
view.dispatch({
effects: [expandRegionEffect.of({
regionIndex: this.regionIndex,
direction,
linesToExpand: 20
})]
});
},
})
);
// Store references for potential updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(container as any)._codeFoldingRoot = root;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(container as any)._updateStyling = (newGutterWidth: number) => {
this.updateContainerStyling(container, newGutterWidth);
};
return container;
}
private updateContainerStyling(container: HTMLElement, gutterWidth: number) {
container.style.marginLeft = `-${gutterWidth}px`;
container.style.width = `calc(100% + ${gutterWidth}px)`;
}
eq(other: CodeFoldingExpandButtonWidget): boolean {
return (
this.regionIndex === other.regionIndex &&
this.direction === other.direction &&
this.canExpandUp === other.canExpandUp &&
this.canExpandDown === other.canExpandDown &&
this.hiddenLineCount === other.hiddenLineCount
);
}
}
// Function to create decorations from folding state
const createDecorations = (state: EditorState, foldingState: FoldingState): DecorationSet => {
const decorations: CodeMirrorRange<Decoration>[] = [];
// Create decorations for each hidden region
foldingState.hiddenRegions.forEach((region, index) => {
// Catch cases where the region is outside the document bounds.
if (
region.startLine < 1 ||
region.startLine > state.doc.lines ||
region.endLine < 1 ||
region.endLine > state.doc.lines
) {
return;
}
const from = state.doc.line(region.startLine).from;
const to = state.doc.line(region.endLine).to;
const hiddenLineCount = region.endLine - region.startLine + 1;
// Create a widget that replaces the hidden region
const widget = new CodeFoldingExpandButtonWidget(
index,
'down', // Default direction
region.canExpandUp,
region.canExpandDown,
hiddenLineCount
);
// Replace the entire hidden region with the expand button
const decoration = Decoration.replace({
widget,
block: true,
inclusive: true,
});
decorations.push(decoration.range(from, to));
});
// Sort decorations by their 'from' position to ensure proper ordering
decorations.sort((a, b) => a.from - b.from);
return Decoration.set(decorations);
};
// Combined StateField that manages both folding state and decorations
interface FoldingStateWithDecorations extends FoldingState {
decorations: DecorationSet;
}
const createFoldingStateWithDecorations = (
references: FileReference[],
totalLines: number,
padding: number = 3
): FoldingStateWithDecorations => {
const visibleRanges = calculateVisibleRanges(references, totalLines, padding);
const hiddenRegions = calculateHiddenRegions(visibleRanges, totalLines);
const foldingState: FoldingState = {
visibleRanges,
hiddenRegions,
totalLines,
references,
padding,
};
return {
...foldingState,
decorations: Decoration.set([]), // Will be updated in the create function
};
};
export const createCodeFoldingExtension = (
references: FileReference[] = [],
padding: number = 3
): Extension => {
const foldingStateField = StateField.define<FoldingStateWithDecorations>({
create(state): FoldingStateWithDecorations {
const totalLines = state.doc.lines;
const stateWithDecorations = createFoldingStateWithDecorations(references, totalLines, padding);
// Create decorations for the initial state
const decorations = createDecorations(state, stateWithDecorations);
return {
...stateWithDecorations,
decorations,
};
},
update(currentState: FoldingStateWithDecorations, transaction: Transaction): FoldingStateWithDecorations {
let newState = currentState;
// Update total lines if document changed
if (transaction.docChanged) {
const newTotalLines = transaction.newDoc.lines;
if (newTotalLines !== currentState.totalLines) {
newState = {
...currentState,
totalLines: newTotalLines,
};
// Recalculate ranges with new total lines
const recalculatedState = recalculateFoldingState(newState);
newState = {
...recalculatedState,
decorations: newState.decorations,
};
}
}
// Handle state effects
for (const effect of transaction.effects) {
if (effect.is(updateReferencesEffect)) {
newState = {
...newState,
references: effect.value,
};
const recalculatedState = recalculateFoldingState(newState);
newState = {
...recalculatedState,
decorations: newState.decorations,
};
} else if (effect.is(expandRegionEffect)) {
const expandedState = expandRegionInternal(newState, effect.value.regionIndex, effect.value.direction, effect.value.linesToExpand);
newState = {
...expandedState,
decorations: newState.decorations,
};
}
}
// Update decorations if state changed or document changed
if (newState !== currentState || transaction.docChanged) {
const decorations = createDecorations(transaction.state, newState);
newState = {
...newState,
decorations,
};
} else {
// Map existing decorations to new document
newState = {
...newState,
decorations: currentState.decorations.map(transaction.changes),
};
}
return newState;
},
provide: field => EditorView.decorations.from(field, state => state.decorations),
});
// View plugin to handle gutter width updates
const gutterUpdatePlugin = EditorView.updateListener.of((update) => {
if (update.geometryChanged) {
const gutterPlugin = update.view.plugin(gutterWidthExtension);
if (gutterPlugin) {
const newGutterWidth = gutterPlugin.width;
// Update all expand button containers
const expandContainers = update.view.dom.querySelectorAll('.cm-code-folding-expand-container');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expandContainers.forEach((container: any) => {
if (container._updateStyling) {
container._updateStyling(newGutterWidth);
}
});
}
}
});
const codeFoldingTheme = EditorView.theme({
'.cm-code-folding-expand-container': {
marginLeft: '0px',
width: '100%',
zIndex: 300,
cursor: 'pointer',
} satisfies CSSProperties,
// Remove top padding from cm-content
'.cm-content': {
paddingTop: '0px',
paddingBottom: '0px',
} satisfies CSSProperties,
// This is required, otherwise the expand button will not be clickable
// when it is rendered over the gutter
'.cm-gutters': {
pointerEvents: 'none',
} satisfies CSSProperties,
});
return [
foldingStateField,
gutterWidthExtension,
gutterUpdatePlugin,
codeFoldingTheme,
];
};