From 4ae9a130218197d30c4b65153850adf6e4958760 Mon Sep 17 00:00:00 2001 From: Caio Gallo Date: Thu, 26 Mar 2026 16:31:59 -0300 Subject: [PATCH 1/2] feat(code-editor): add collapse unchanged lines extension with expand controls --- .../hooks/collapseUnchangedExtension.test.ts | 159 ++++++++++ .../hooks/collapseUnchangedExtension.ts | 298 ++++++++++++++++++ .../code-editor/hooks/useCodeMirror.ts | 15 +- .../features/code-editor/theme/editorTheme.ts | 74 +++-- 4 files changed, 522 insertions(+), 24 deletions(-) create mode 100644 apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts create mode 100644 apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts 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": { From 98ec4e69619cd9dcd8615567d773c6f0110c1306 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 31 Mar 2026 12:50:20 +0200 Subject: [PATCH 2/2] implement feedback --- .../hooks/collapseUnchangedExtension.test.ts | 75 ++++++----- .../hooks/collapseUnchangedExtension.ts | 125 +++++++++++------- .../features/code-editor/theme/editorTheme.ts | 48 +++---- 3 files changed, 140 insertions(+), 108 deletions(-) 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 index d11553ca1..26c58fcd8 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts +++ b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts @@ -15,6 +15,20 @@ function makeState(lineCount: number): EditorState { 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 }, @@ -27,20 +41,14 @@ describe("mapPosBetweenSides", () => { }); 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); }); @@ -50,32 +58,24 @@ describe("mapPosBetweenSides", () => { }); 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 }, - ]; + const ranges: CollapsedRange[] = [range(1, 5), range(12, 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 }]); + expect(result).toEqual([range(12, 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); @@ -83,26 +83,20 @@ describe("applyExpandEffect", () => { expect(result).toEqual(ranges); }); - it("expandUp reveals lines from the top of the range", () => { + 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([ - { fromLine: 1, toLine: 5 }, - { fromLine: 12, toLine: 15 }, - ]); + expect(result).toEqual([range(1, 5), range(15, 18, 12, 18)]); }); - it("expandDown reveals lines from the bottom of the range", () => { + 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([ - { fromLine: 1, toLine: 5 }, - { fromLine: 15, toLine: 18 }, - ]); + expect(result).toEqual([range(1, 5), range(12, 15, 12, 18)]); }); it("expandUp removes range when lines exceed range size", () => { @@ -110,7 +104,7 @@ describe("applyExpandEffect", () => { const effect = expandUp.of({ pos, lines: 100 }); const result = applyExpandEffect(ranges, state, effect); - expect(result).toEqual([{ fromLine: 12, toLine: 18 }]); + expect(result).toEqual([range(12, 18)]); }); it("expandDown removes range when lines exceed range size", () => { @@ -118,14 +112,30 @@ describe("applyExpandEffect", () => { const effect = expandDown.of({ pos, lines: 100 }); const result = applyExpandEffect(ranges, state, effect); - expect(result).toEqual([{ fromLine: 12, toLine: 18 }]); + 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[] = [{ fromLine: 5, toLine: 3 }]; + const ranges: CollapsedRange[] = [range(5, 3)]; const deco = buildDecorations(state, ranges); expect(deco.size).toBe(0); @@ -133,10 +143,7 @@ describe("buildDecorations", () => { it("creates decorations for valid ranges", () => { const state = makeState(20); - const ranges: CollapsedRange[] = [ - { fromLine: 3, toLine: 7 }, - { fromLine: 15, toLine: 18 }, - ]; + const ranges: CollapsedRange[] = [range(3, 7), range(15, 18)]; const deco = buildDecorations(state, ranges); expect(deco.size).toBe(2); @@ -151,7 +158,7 @@ describe("buildDecorations", () => { it("creates single-line range decoration", () => { const state = makeState(10); - const ranges: CollapsedRange[] = [{ fromLine: 5, toLine: 5 }]; + 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 index dfe6ce986..4c59ee745 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts +++ b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts @@ -10,27 +10,41 @@ import { Decoration, type DecorationSet, EditorView, + GutterMarker, + gutterWidgetClass, 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; + 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(); } @@ -39,7 +53,9 @@ class ExpandWidget extends WidgetType { return ( this.collapsedLines === other.collapsedLines && this.showUp === other.showUp && - this.showDown === other.showDown + this.showDown === other.showDown && + this.expandableUp === other.expandableUp && + this.expandableDown === other.expandableDown ); } @@ -47,44 +63,25 @@ class ExpandWidget extends WidgetType { 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) => { + 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); }); - 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(upBtn); } - outer.appendChild(gutterArea); - - // Label area - const label = document.createElement("span"); - label.className = "cm-collapsed-label"; - label.textContent = `${this.collapsedLines} unchanged lines`; + 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); @@ -93,6 +90,21 @@ class ExpandWidget extends WidgetType { }); 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; } @@ -169,13 +181,21 @@ export function buildDecorations( 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; + 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, !isFirst, !isLast), + widget: new ExpandWidget( + lines, + canExpandUp, + canExpandDown, + expandableUp, + expandableDown, + ), block: true, }), ); @@ -198,14 +218,21 @@ export function computeInitialRanges( 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 + 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 }); + ranges.push({ + fromLine: collapseFrom, + toLine: collapseTo, + limitFromLine: limitFrom, + limitToLine: limitTo, + }); } if (!chunk) break; @@ -239,16 +266,16 @@ export function applyExpandEffect( const { lines } = effect.value as { pos: number; lines: number }; - if (isDown) { + if (isUp) { const newFrom = range.fromLine + lines; if (newFrom > range.toLine) return []; - return [{ fromLine: newFrom, toLine: range.toLine }]; + return [{ ...range, fromLine: newFrom }]; } - if (isUp) { + if (isDown) { const newTo = range.toLine - lines; if (newTo < range.fromLine) return []; - return [{ fromLine: range.fromLine, toLine: newTo }]; + return [{ ...range, toLine: newTo }]; } return [range]; @@ -274,8 +301,7 @@ export function gradualCollapseUnchanged({ let newRanges = prev.ranges; let changed = false; - // If document changed, recompute from scratch - if (tr.docChanged) { + if (tr.docChanged || (prev.ranges.length === 0 && getChunks(tr.state))) { newRanges = computeInitialRanges(tr.state, margin, minSize); changed = true; } @@ -294,5 +320,10 @@ export function gradualCollapseUnchanged({ provide: (f) => EditorView.decorations.from(f, (v) => v.deco), }); - return [collapsedField]; + 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/theme/editorTheme.ts b/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts index dea1aa8ab..8b5b8725a 100644 --- a/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts +++ b/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts @@ -342,8 +342,9 @@ export const mergeViewTheme = EditorView.baseTheme({ ".cm-collapsed-context": { display: "flex", alignItems: "center", - gap: "0", - padding: "0", + justifyContent: "flex-start", + gap: "4px", + padding: "0 8px", borderTop: "1px solid var(--gray-6)", borderBottom: "1px solid var(--gray-6)", fontSize: "12px", @@ -359,47 +360,40 @@ export const mergeViewTheme = EditorView.baseTheme({ 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-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", - justifyContent: "center", + gap: "4px", border: "none", borderRadius: "3px", cursor: "pointer", - padding: "3px", + padding: "3px 6px", 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", + fontFamily: "inherit", fontSize: "11px", - opacity: "0.7", + 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", },