diff --git a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts new file mode 100644 index 000000000..26c58fcd8 --- /dev/null +++ b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts @@ -0,0 +1,166 @@ +import { EditorState } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; +import { + applyExpandEffect, + buildDecorations, + type CollapsedRange, + expandAll, + expandDown, + expandUp, + mapPosBetweenSides, +} from "./collapseUnchangedExtension"; + +function makeState(lineCount: number): EditorState { + const lines = Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`); + return EditorState.create({ doc: lines.join("\n") }); +} + +function range( + from: number, + to: number, + limitFrom?: number, + limitTo?: number, +): CollapsedRange { + return { + fromLine: from, + toLine: to, + limitFromLine: limitFrom ?? from, + limitToLine: limitTo ?? to, + }; +} + +describe("mapPosBetweenSides", () => { + const chunks = [ + { fromA: 10, toA: 20, fromB: 10, toB: 25 }, + { fromA: 50, toA: 60, fromB: 55, toB: 70 }, + ]; + + it("maps position before first chunk", () => { + expect(mapPosBetweenSides(5, chunks, true)).toBe(5); + expect(mapPosBetweenSides(5, chunks, false)).toBe(5); + }); + + it("maps position between chunks from side A", () => { + expect(mapPosBetweenSides(30, chunks, true)).toBe(35); + }); + + it("maps position between chunks from side B", () => { + expect(mapPosBetweenSides(35, chunks, false)).toBe(30); + }); + + it("maps position after last chunk from side A", () => { + expect(mapPosBetweenSides(80, chunks, true)).toBe(90); + }); + + it("handles empty chunks array", () => { + expect(mapPosBetweenSides(42, [], true)).toBe(42); + expect(mapPosBetweenSides(42, [], false)).toBe(42); + }); + + it("maps position at exact chunk boundary", () => { + expect(mapPosBetweenSides(10, chunks, true)).toBe(10); + }); +}); + +describe("applyExpandEffect", () => { + const state = makeState(20); + + const ranges: CollapsedRange[] = [range(1, 5), range(12, 18)]; + + it("expandAll removes the targeted range", () => { + const pos = state.doc.line(3).from; + const effect = expandAll.of(pos); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([range(12, 18)]); + }); + + it("expandAll leaves non-targeted ranges intact", () => { + const pos = state.doc.line(8).from; + const effect = expandAll.of(pos); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual(ranges); + }); + + it("expandUp reveals lines above the collapsed range", () => { + const pos = state.doc.line(14).from; + const effect = expandUp.of({ pos, lines: 3 }); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([range(1, 5), range(15, 18, 12, 18)]); + }); + + it("expandDown reveals lines below the collapsed range", () => { + const pos = state.doc.line(14).from; + const effect = expandDown.of({ pos, lines: 3 }); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([range(1, 5), range(12, 15, 12, 18)]); + }); + + it("expandUp removes range when lines exceed range size", () => { + const pos = state.doc.line(3).from; + const effect = expandUp.of({ pos, lines: 100 }); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([range(12, 18)]); + }); + + it("expandDown removes range when lines exceed range size", () => { + const pos = state.doc.line(3).from; + const effect = expandDown.of({ pos, lines: 100 }); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([range(12, 18)]); + }); + + it("preserves original boundaries through multiple expansions", () => { + const pos = state.doc.line(14).from; + const first = applyExpandEffect( + ranges, + state, + expandUp.of({ pos, lines: 2 }), + ); + const second = applyExpandEffect( + first, + state, + expandDown.of({ pos: state.doc.line(16).from, lines: 2 }), + ); + + expect(second).toEqual([range(1, 5), range(14, 16, 12, 18)]); + }); +}); + +describe("buildDecorations", () => { + it("skips ranges where fromLine > toLine", () => { + const state = makeState(10); + const ranges: CollapsedRange[] = [range(5, 3)]; + const deco = buildDecorations(state, ranges); + + expect(deco.size).toBe(0); + }); + + it("creates decorations for valid ranges", () => { + const state = makeState(20); + const ranges: CollapsedRange[] = [range(3, 7), range(15, 18)]; + const deco = buildDecorations(state, ranges); + + expect(deco.size).toBe(2); + }); + + it("handles empty ranges array", () => { + const state = makeState(10); + const deco = buildDecorations(state, []); + + expect(deco.size).toBe(0); + }); + + it("creates single-line range decoration", () => { + const state = makeState(10); + const ranges: CollapsedRange[] = [range(5, 5)]; + const deco = buildDecorations(state, ranges); + + expect(deco.size).toBe(1); + }); +}); diff --git a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts new file mode 100644 index 000000000..4c59ee745 --- /dev/null +++ b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts @@ -0,0 +1,329 @@ +import { getChunks, mergeViewSiblings } from "@codemirror/merge"; +import { + type EditorState, + type Extension, + RangeSetBuilder, + StateEffect, + StateField, +} from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + GutterMarker, + gutterWidgetClass, + WidgetType, +} from "@codemirror/view"; + +const EXPAND_LINES = 20; + +export interface CollapsedRange { + fromLine: number; + toLine: number; + limitFromLine: number; + limitToLine: number; +} + +export const expandUp = StateEffect.define<{ pos: number; lines: number }>(); +export const expandDown = StateEffect.define<{ pos: number; lines: number }>(); +export const expandAll = StateEffect.define(); + +const SVG_ARROW_LINE_DOWN = ``; +const SVG_ARROW_LINE_UP = ``; +const SVG_ARROWS_OUT_LINE_VERTICAL = ``; + +class CollapsedGutterMarker extends GutterMarker { + elementClass = "cm-collapsed-gutter-el"; +} + +const collapsedGutterMarker = new CollapsedGutterMarker(); + +class ExpandWidget extends WidgetType { + constructor( + readonly collapsedLines: number, + readonly showUp: boolean, + readonly showDown: boolean, + readonly expandableUp: number, + readonly expandableDown: number, + ) { + super(); + } + + eq(other: ExpandWidget) { + return ( + this.collapsedLines === other.collapsedLines && + this.showUp === other.showUp && + this.showDown === other.showDown && + this.expandableUp === other.expandableUp && + this.expandableDown === other.expandableDown + ); + } + + toDOM(view: EditorView) { + const outer = document.createElement("div"); + outer.className = "cm-collapsed-context"; + + if (this.showUp) { + const upBtn = document.createElement("button"); + upBtn.className = "cm-collapsed-expand-btn"; + const upLines = Math.min(EXPAND_LINES, this.collapsedLines); + upBtn.title = `Expand ${upLines} lines`; + upBtn.innerHTML = `${SVG_ARROW_LINE_DOWN}${upLines} lines`; + upBtn.addEventListener("mousedown", (e) => { + e.preventDefault(); + const pos = view.posAtDOM(outer); + view.dispatch({ effects: expandUp.of({ pos, lines: EXPAND_LINES }) }); + syncSibling(view, expandUp, pos, EXPAND_LINES); + }); + outer.appendChild(upBtn); + } + + const label = document.createElement("button"); + label.className = "cm-collapsed-expand-btn"; + label.title = `Expand all ${this.collapsedLines} lines`; + label.innerHTML = `${SVG_ARROWS_OUT_LINE_VERTICAL}All ${this.collapsedLines} lines`; + label.addEventListener("mousedown", (e) => { + e.preventDefault(); + const pos = view.posAtDOM(outer); + view.dispatch({ effects: expandAll.of(pos) }); + syncSibling(view, expandAll, pos); + }); + outer.appendChild(label); + + if (this.showDown) { + const downBtn = document.createElement("button"); + downBtn.className = "cm-collapsed-expand-btn"; + const downLines = Math.min(EXPAND_LINES, this.collapsedLines); + downBtn.title = `Expand ${downLines} lines`; + downBtn.innerHTML = `${SVG_ARROW_LINE_UP}${downLines} lines`; + downBtn.addEventListener("mousedown", (e) => { + e.preventDefault(); + const pos = view.posAtDOM(outer); + view.dispatch({ effects: expandDown.of({ pos, lines: EXPAND_LINES }) }); + syncSibling(view, expandDown, pos, EXPAND_LINES); + }); + outer.appendChild(downBtn); + } + + return outer; + } + + ignoreEvent(e: Event) { + return e instanceof MouseEvent; + } + + get estimatedHeight() { + return 33; + } +} + +function syncSibling( + view: EditorView, + effect: typeof expandUp | typeof expandDown, + pos: number, + lines?: number, +): void; +function syncSibling( + view: EditorView, + effect: typeof expandAll, + pos: number, +): void; +function syncSibling( + view: EditorView, + effect: typeof expandUp | typeof expandDown | typeof expandAll, + pos: number, + lines?: number, +): void { + const siblings = mergeViewSiblings(view); + if (!siblings) return; + + const info = getChunks(view.state); + if (!info) return; + + const otherView = siblings.a === view ? siblings.b : siblings.a; + const mappedPos = mapPosBetweenSides(pos, info.chunks, info.side === "a"); + + if (effect === expandAll) { + otherView.dispatch({ effects: expandAll.of(mappedPos) }); + } else if (lines !== undefined) { + otherView.dispatch({ + effects: (effect as typeof expandUp | typeof expandDown).of({ + pos: mappedPos, + lines, + }), + }); + } +} + +export function mapPosBetweenSides( + pos: number, + chunks: readonly { fromA: number; toA: number; fromB: number; toB: number }[], + isA: boolean, +): number { + let startOur = 0; + let startOther = 0; + for (let i = 0; ; i++) { + const next = i < chunks.length ? chunks[i] : null; + if (!next || (isA ? next.fromA : next.fromB) >= pos) { + return startOther + (pos - startOur); + } + [startOur, startOther] = isA ? [next.toA, next.toB] : [next.toB, next.toA]; + } +} + +export function buildDecorations( + state: EditorState, + ranges: CollapsedRange[], +): DecorationSet { + const builder = new RangeSetBuilder(); + for (const range of ranges) { + if (range.fromLine > range.toLine) continue; + const lines = range.toLine - range.fromLine + 1; + const from = state.doc.line(range.fromLine).from; + const to = state.doc.line(range.toLine).to; + const expandableUp = range.fromLine - range.limitFromLine; + const expandableDown = range.limitToLine - range.toLine; + const canExpandUp = expandableUp > 0 && lines >= EXPAND_LINES; + const canExpandDown = expandableDown > 0 && lines >= EXPAND_LINES; + builder.add( + from, + to, + Decoration.replace({ + widget: new ExpandWidget( + lines, + canExpandUp, + canExpandDown, + expandableUp, + expandableDown, + ), + block: true, + }), + ); + } + return builder.finish(); +} + +export function computeInitialRanges( + state: EditorState, + margin: number, + minSize: number, +): CollapsedRange[] { + const info = getChunks(state); + if (!info) return []; + + const { chunks, side } = info; + const isA = side === "a"; + const ranges: CollapsedRange[] = []; + let prevLine = 1; + + for (let i = 0; ; i++) { + const chunk = i < chunks.length ? chunks[i] : null; + const limitFrom = i ? prevLine : 1; + const limitTo = chunk + ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 + : state.doc.lines; + const collapseFrom = i ? prevLine + margin : 1; + const collapseTo = chunk ? limitTo - margin : state.doc.lines; + const lines = collapseTo - collapseFrom + 1; + + if (lines >= minSize) { + ranges.push({ + fromLine: collapseFrom, + toLine: collapseTo, + limitFromLine: limitFrom, + limitToLine: limitTo, + }); + } + + if (!chunk) break; + prevLine = state.doc.lineAt( + Math.min(state.doc.length, isA ? chunk.toA : chunk.toB), + ).number; + } + + return ranges; +} + +export function applyExpandEffect( + ranges: CollapsedRange[], + state: EditorState, + effect: StateEffect, +): CollapsedRange[] { + const isAll = effect.is(expandAll); + const isUp = effect.is(expandUp); + const isDown = effect.is(expandDown); + + const pos = isAll + ? (effect.value as number) + : (effect.value as { pos: number; lines: number }).pos; + + return ranges.flatMap((range) => { + const from = state.doc.line(range.fromLine).from; + const to = state.doc.line(range.toLine).to; + if (pos < from || pos > to) return [range]; + + if (isAll) return []; + + const { lines } = effect.value as { pos: number; lines: number }; + + if (isUp) { + const newFrom = range.fromLine + lines; + if (newFrom > range.toLine) return []; + return [{ ...range, fromLine: newFrom }]; + } + + if (isDown) { + const newTo = range.toLine - lines; + if (newTo < range.fromLine) return []; + return [{ ...range, toLine: newTo }]; + } + + return [range]; + }); +} + +export function gradualCollapseUnchanged({ + margin = 3, + minSize = 4, +}: { + margin?: number; + minSize?: number; +} = {}): Extension { + const collapsedField = StateField.define<{ + ranges: CollapsedRange[]; + deco: DecorationSet; + }>({ + create(state) { + const ranges = computeInitialRanges(state, margin, minSize); + return { ranges, deco: buildDecorations(state, ranges) }; + }, + update(prev, tr) { + let newRanges = prev.ranges; + let changed = false; + + if (tr.docChanged || (prev.ranges.length === 0 && getChunks(tr.state))) { + newRanges = computeInitialRanges(tr.state, margin, minSize); + changed = true; + } + + for (const e of tr.effects) { + if (e.is(expandUp) || e.is(expandDown) || e.is(expandAll)) { + newRanges = applyExpandEffect(newRanges, tr.state, e); + changed = true; + } + } + + if (!changed) return prev; + + return { ranges: newRanges, deco: buildDecorations(tr.state, newRanges) }; + }, + provide: (f) => EditorView.decorations.from(f, (v) => v.deco), + }); + + const collapsedGutterFill = gutterWidgetClass.of((_view, widget) => { + if (widget instanceof ExpandWidget) return collapsedGutterMarker; + return null; + }); + + return [collapsedField, collapsedGutterFill]; +} diff --git a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts index e4f9610dc..9a35b149f 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts +++ b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts @@ -5,6 +5,7 @@ import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { trpcClient } from "@renderer/trpc/client"; import { handleExternalAppAction } from "@utils/handleExternalAppAction"; import { useEffect, useRef } from "react"; +import { gradualCollapseUnchanged } from "./collapseUnchangedExtension"; type EditorInstance = EditorView | MergeView; @@ -61,13 +62,12 @@ const createMergeControls = (onReject?: () => void) => { }; }; +const collapseExtension = (loadFullFiles?: boolean): Extension => + loadFullFiles ? [] : gradualCollapseUnchanged({ margin: 3, minSize: 4 }); + const getBaseDiffConfig = ( - options?: { loadFullFiles?: boolean; wordDiffs?: boolean }, onReject?: () => void, ): Partial[0]> => ({ - collapseUnchanged: options?.loadFullFiles - ? undefined - : { margin: 3, minSize: 4 }, highlightChanges: false, gutter: true, mergeControls: createMergeControls(onReject), @@ -93,7 +93,6 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { }); } else if (options.mode === "split") { const diffConfig = getBaseDiffConfig( - { loadFullFiles: options.loadFullFiles, wordDiffs: options.wordDiffs }, options.onContentChange ? () => { if (instanceRef.current instanceof MergeView) { @@ -116,6 +115,8 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { }) : []; + const collapse = collapseExtension(options.loadFullFiles); + instanceRef.current = new MergeView({ a: { doc: options.original, @@ -123,6 +124,7 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { ...options.extensions, EditorView.editable.of(false), EditorState.readOnly.of(true), + collapse, ], }, b: { @@ -132,6 +134,7 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { ...(Array.isArray(updateListener) ? updateListener : [updateListener]), + collapse, ], }, ...diffConfig, @@ -140,7 +143,6 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { }); } else { const diffConfig = getBaseDiffConfig( - { loadFullFiles: options.loadFullFiles, wordDiffs: options.wordDiffs }, options.onContentChange ? () => { if (instanceRef.current instanceof EditorView) { @@ -159,6 +161,7 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { original: options.original, ...diffConfig, }), + collapseExtension(options.loadFullFiles), ], parent: containerRef.current, }); diff --git a/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts b/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts index 4f5b61279..8b5b8725a 100644 --- a/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts +++ b/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts @@ -339,29 +339,61 @@ export const mergeViewTheme = EditorView.baseTheme({ }, }, }, - ".cm-collapsedLines": { - padding: "5px 5px 5px 10px", - cursor: "pointer", - "&:before": { - content: '"⦚"', - marginInlineEnd: "7px", - }, - "&:after": { - content: '"⦚"', - marginInlineStart: "7px", - }, + ".cm-collapsed-context": { + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + gap: "4px", + padding: "0 8px", + borderTop: "1px solid var(--gray-6)", + borderBottom: "1px solid var(--gray-6)", + fontSize: "12px", + lineHeight: "1", + userSelect: "none", + minHeight: "26px", }, - "&light .cm-collapsedLines": { + "&light .cm-collapsed-context": { + background: "#e8e9e3", color: "#3a4036", - background: - "linear-gradient(to bottom, transparent 0, #e4e5de 30%, #e4e5de 70%, transparent 100%)", }, - "&dark .cm-collapsedLines": { - color: "#e6e6e6", - background: - "linear-gradient(to bottom, transparent 0, #1e1e28 30%, #1e1e28 70%, transparent 100%)", + "&dark .cm-collapsed-context": { + background: "#1a1a24", + color: "#9898b6", + }, + ".cm-collapsed-gutter-el": { + borderTop: "1px solid var(--gray-6)", + borderBottom: "1px solid var(--gray-6)", + }, + "&light .cm-collapsed-gutter-el": { + background: "#e8e9e3", + }, + "&dark .cm-collapsed-gutter-el": { + background: "#1a1a24", + }, + ".cm-collapsed-expand-btn": { + display: "inline-flex", + alignItems: "center", + gap: "4px", + border: "none", + borderRadius: "3px", + cursor: "pointer", + padding: "3px 6px", + lineHeight: "0", + background: "transparent", + color: "inherit", + fontFamily: "inherit", + fontSize: "11px", + opacity: "0.6", + transition: "opacity 0.15s ease, background 0.15s ease", + "& span": { + lineHeight: "1", + }, + "&:hover": { + opacity: "1", + background: "var(--gray-a4)", + }, }, - ".cm-changeGutter": { width: "3px", paddingLeft: "1px" }, + ".cm-changeGutter": { width: "3px", paddingLeft: "0px" }, "&light.cm-merge-a .cm-changedLineGutter, &light .cm-deletedLineGutter": { background: "#e43", },