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..d11553ca1 --- /dev/null +++ b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts @@ -0,0 +1,159 @@ +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") }); +} + +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", () => { + // After first chunk: startOur=20, startOther=25 + // pos=30 → 25 + (30 - 20) = 35 + expect(mapPosBetweenSides(30, chunks, true)).toBe(35); + }); + + it("maps position between chunks from side B", () => { + // After first chunk: startOur=25, startOther=20 + // pos=35 → 20 + (35 - 25) = 30 + expect(mapPosBetweenSides(35, chunks, false)).toBe(30); + }); + + it("maps position after last chunk from side A", () => { + // After second chunk: startOur=60, startOther=70 + // pos=80 → 70 + (80 - 60) = 90 + 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", () => { + // pos=10 equals fromA of first chunk → startOur=0, startOther=0 + // 10 >= 10, so returns 0 + (10 - 0) = 10 + expect(mapPosBetweenSides(10, chunks, true)).toBe(10); + }); +}); + +describe("applyExpandEffect", () => { + // 20-line doc: each line is "line N\n", line 1 starts at pos 0 + const state = makeState(20); + + const ranges: CollapsedRange[] = [ + { fromLine: 1, toLine: 5 }, + { fromLine: 12, toLine: 18 }, + ]; + + it("expandAll removes the targeted range", () => { + // pos inside range 1 (fromLine=1 → pos=0, toLine=5) + const pos = state.doc.line(3).from; + const effect = expandAll.of(pos); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([{ fromLine: 12, toLine: 18 }]); + }); + + it("expandAll leaves non-targeted ranges intact", () => { + // pos outside both ranges + 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 from the top of the range", () => { + const pos = state.doc.line(14).from; + const effect = expandUp.of({ pos, lines: 3 }); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([ + { fromLine: 1, toLine: 5 }, + { fromLine: 12, toLine: 15 }, + ]); + }); + + it("expandDown reveals lines from the bottom of the range", () => { + const pos = state.doc.line(14).from; + const effect = expandDown.of({ pos, lines: 3 }); + const result = applyExpandEffect(ranges, state, effect); + + expect(result).toEqual([ + { fromLine: 1, toLine: 5 }, + { fromLine: 15, toLine: 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([{ fromLine: 12, toLine: 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([{ fromLine: 12, toLine: 18 }]); + }); +}); + +describe("buildDecorations", () => { + it("skips ranges where fromLine > toLine", () => { + const state = makeState(10); + const ranges: CollapsedRange[] = [{ fromLine: 5, toLine: 3 }]; + const deco = buildDecorations(state, ranges); + + expect(deco.size).toBe(0); + }); + + it("creates decorations for valid ranges", () => { + const state = makeState(20); + const ranges: CollapsedRange[] = [ + { fromLine: 3, toLine: 7 }, + { fromLine: 15, toLine: 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[] = [{ fromLine: 5, toLine: 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..dfe6ce986 --- /dev/null +++ b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts @@ -0,0 +1,298 @@ +import { getChunks, mergeViewSiblings } from "@codemirror/merge"; +import { + type EditorState, + type Extension, + RangeSetBuilder, + StateEffect, + StateField, +} from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + WidgetType, +} from "@codemirror/view"; + +const EXPAND_LINES = 20; + +export interface CollapsedRange { + /** First collapsed line number (1-based) */ + fromLine: number; + /** Last collapsed line number (1-based) */ + toLine: number; +} + +export const expandUp = StateEffect.define<{ pos: number; lines: number }>(); +export const expandDown = StateEffect.define<{ pos: number; lines: number }>(); +export const expandAll = StateEffect.define(); + +class ExpandWidget extends WidgetType { + constructor( + readonly collapsedLines: number, + readonly showUp: boolean, + readonly showDown: boolean, + ) { + super(); + } + + eq(other: ExpandWidget) { + return ( + this.collapsedLines === other.collapsedLines && + this.showUp === other.showUp && + this.showDown === other.showDown + ); + } + + toDOM(view: EditorView) { + const outer = document.createElement("div"); + outer.className = "cm-collapsed-context"; + + // Left gutter area with stacked arrows (GitHub-style) + const gutterArea = document.createElement("div"); + gutterArea.className = "cm-collapsed-gutter"; + + if (this.showUp) { + const upButton = document.createElement("button"); + upButton.className = "cm-collapsed-expand-btn"; + upButton.title = `Expand ${Math.min(EXPAND_LINES, this.collapsedLines)} lines up`; + upButton.innerHTML = ``; + upButton.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); + }); + gutterArea.appendChild(upButton); + } + + if (this.showDown) { + const downButton = document.createElement("button"); + downButton.className = "cm-collapsed-expand-btn"; + downButton.title = `Expand ${Math.min(EXPAND_LINES, this.collapsedLines)} lines down`; + downButton.innerHTML = ``; + downButton.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); + }); + gutterArea.appendChild(downButton); + } + + outer.appendChild(gutterArea); + + // Label area + const label = document.createElement("span"); + label.className = "cm-collapsed-label"; + label.textContent = `${this.collapsedLines} unchanged 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); + + 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 isFirst = range.fromLine === 1; + const isLast = range.toLine === state.doc.lines; + builder.add( + from, + to, + Decoration.replace({ + widget: new ExpandWidget(lines, !isFirst, !isLast), + 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 collapseFrom = i ? prevLine + margin : 1; + const collapseTo = chunk + ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin + : state.doc.lines; + const lines = collapseTo - collapseFrom + 1; + + if (lines >= minSize) { + ranges.push({ fromLine: collapseFrom, toLine: collapseTo }); + } + + 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 (isDown) { + const newFrom = range.fromLine + lines; + if (newFrom > range.toLine) return []; + return [{ fromLine: newFrom, toLine: range.toLine }]; + } + + if (isUp) { + const newTo = range.toLine - lines; + if (newTo < range.fromLine) return []; + return [{ fromLine: range.fromLine, 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 document changed, recompute from scratch + if (tr.docChanged) { + 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), + }); + + return [collapsedField]; +} 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..dea1aa8ab 100644 --- a/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts +++ b/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts @@ -339,27 +339,65 @@ 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", + gap: "0", + padding: "0", + 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": { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "0", + flexShrink: "0", + paddingLeft: "4px", + paddingRight: "4px", + alignSelf: "stretch", + }, + ".cm-collapsed-expand-btn": { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + border: "none", + borderRadius: "3px", + cursor: "pointer", + padding: "3px", + lineHeight: "0", + background: "transparent", + color: "inherit", + opacity: "0.5", + transition: "opacity 0.15s ease, background 0.15s ease, color 0.15s ease", + "&:hover": { + opacity: "1", + background: "var(--accent-a4)", + color: "var(--accent-11)", + }, + }, + ".cm-collapsed-label": { + cursor: "pointer", + padding: "2px 8px", + borderRadius: "3px", + fontSize: "11px", + opacity: "0.7", + "&:hover": { + opacity: "1", + background: "var(--gray-a4)", + }, }, ".cm-changeGutter": { width: "3px", paddingLeft: "1px" }, "&light.cm-merge-a .cm-changedLineGutter, &light .cm-deletedLineGutter": {