mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
505 lines
17 KiB
TypeScript
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,
|
|
];
|
|
};
|
|
|