From 2fb63dbb549ff3df4c3584e0822a3656f4926b8f Mon Sep 17 00:00:00 2001 From: oskarnrk Date: Thu, 8 Oct 2026 17:22:39 +0200 Subject: [PATCH] feat: Add first implementation of split diff view plugin for Claude Code - Create marketplace.json and plugin.json for plugin metadata - Implement GitHub Actions workflow for testing - Add README.md with installation instructions and usage details - Develop hooks for rendering split diffs in two columns - Implement split-rows logic to handle diff data - Add tests for split-rows functionality - Configure TypeScript settings --- .claude-plugin/marketplace.json | 15 +++ .claude-plugin/plugin.json | 7 ++ .github/workflows/tests.yaml | 23 ++++ README.md | 94 ++++++++++++++++ hooks/hooks.json | 4 + hooks/register.ts | 189 ++++++++++++++++++++++++++++++++ hooks/split-rows.ts | 140 +++++++++++++++++++++++ tests/split-rows.test.ts | 74 +++++++++++++ tsconfig.json | 3 + 9 files changed, 549 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .github/workflows/tests.yaml create mode 100644 README.md create mode 100644 hooks/hooks.json create mode 100644 hooks/register.ts create mode 100644 hooks/split-rows.ts create mode 100644 tests/split-rows.test.ts create mode 100644 tsconfig.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..40c715e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "neteye-platform", + "description": "Claude Code plugins and mods by NetEye Platform developers", + "owner": { + "name": "NetEye Platform", + "url": "https://github.com/neteye-platform" + }, + "plugins": [ + { + "name": "cc-split-diff-view", + "source": "./", + "description": "Edit and Write diffs in two side-by-side columns instead of +/- lines" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..2c252f9 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "cc-split-diff-view", + "version": "0.1.0", + "description": "Draws Edit and Write diffs in two side-by-side columns: removed lines on the left, added lines on the right, in Claude Code's own diff colors", + "author": { "name": "oskarnrk", "url": "https://github.com/oskarnrk" }, + "repository": "https://github.com/neteye-platform/cc-split-diff-view" +} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..102f16c --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,23 @@ +name: Tests + +on: + pull_request: + branches: + - "**" + +permissions: + contents: read + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Run tests + run: claude plugin test diff --git a/README.md b/README.md new file mode 100644 index 0000000..336e51b --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Split diff view for Claude Code + +A [Claude Code mod](https://code.claude.com/docs/en/plugins/mods/overview) that +draws the diff of every `Edit` and `Write` in **two side-by-side columns** +instead of one stacked `-`/`+` list: + +- removed lines on the **left**, added lines on the **right** +- each side keeps its real line numbers +- a changed line sits next to the line that replaced it +- long runs between hunks collapse to `⋯ N unchanged lines` +- colors are Claude Code's own diff colors (`diffRemoved` / `diffAdded` theme + keys), so they follow your `/theme` +- syntax highlighting comes from Claude Code's own highlighter (the `Code` + element, the one the built-in `/diff` pane uses), with the language taken + from the file's extension + +Errors, edits held for review, brand-new files and anything without a diff +keep Claude Code's normal drawing. + +## Requirements + +- Claude Code **v2.1.287 or later** (the version that introduced mods; + they are on by default). Check with `claude --version`. +- Mods must be allowed to load: not started with `--safe-mode` or `--bare`, + and not blocked by your organization's managed settings. + +## Install + +From a terminal: + +```bash +claude plugin marketplace add neteye-platform/cc-split-diff-view +claude plugin install cc-split-diff-view@neteye-platform +``` + +Or from inside a Claude Code session: + +```text +/plugin marketplace add neteye-platform/cc-split-diff-view +/plugin install cc-split-diff-view@neteye-platform +``` + +No download or build step is needed: Claude Code fetches the repository and +loads the TypeScript directly. Start a new session (or run `/reload-plugins`), +then ask Claude to edit a file. + +To check it loaded, run `/plugin` and look for `cc-split-diff-view` under +**Installed**. + +## Update + +Claude Code caches an installed plugin by version, so updates arrive when the +`version` in `.claude-plugin/plugin.json` is bumped: + +```bash +claude plugin marketplace update neteye-platform +``` + +## Uninstall + +```bash +claude plugin uninstall cc-split-diff-view@neteye-platform +``` + +You can also turn it off without removing it from the **Installed** tab of +`/plugin`. + +## Try it for one session without installing + +```bash +git clone https://github.com/neteye-platform/cc-split-diff-view.git +claude --plugin-dir ./cc-split-diff-view +``` + +The mod hot-reloads when you save a file in that directory. + +## Limitations + +- Only `Edit` and `Write` are redrawn. The permission prompt is not a render + site, so a mod cannot change it. +- Each line is highlighted on its own, so something that spans lines (a block + comment or a template string) can be colored as plain code when only part of + it is in the diff. +- Hooks stay active in the VS Code extension, `claude -p` and cloud sessions, + but the interface part only draws in the terminal and the Desktop app's Code + tab. +- Mod events and render-site props can change between Claude Code releases. + If the diff suddenly looks like the default again, run + `claude plugin validate .` and check the debug log. + +## Credits + +Developed by Oscar Zambotti ([@oskarnrk](https://github.com/oskarnrk)) in the +NetEye Platform development team, with Claude Code. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..b363b98 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,4 @@ +{ + "description": "Split diff view: Edit and Write diffs drawn in two columns", + "modules": ["./register.ts"] +} diff --git a/hooks/register.ts b/hooks/register.ts new file mode 100644 index 0000000..89edb33 --- /dev/null +++ b/hooks/register.ts @@ -0,0 +1,189 @@ +import type { ElementTable, On } from 'claude-code' + +import { splitRows } from './split-rows.js' +import type { Cell, SplitRow } from './split-rows.js' + +/** Tools whose result is a diff this mod redraws. */ +const TOOLS = ['Edit', 'Write'] + +/** Rows drawn before "… N more rows". */ +const MAX_ROWS = 200 + +type Kit = Pick, 'Box' | 'Text' | 'Code'> + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** + * Draws the result block of an Edit or Write call as a split diff: removed + * lines in the left column and added lines in the right, with line numbers. + * + * @param on the engine's registrar + */ +export function register(on: On) { + on('ui.render', { component: 'ToolResult' }, async ($, e, next) => { + const { tool, output, isErrored } = e.props + if (!TOOLS.includes(tool) || isErrored || !isRecord(output)) return next(e) + // An edit held for review wrote nothing, so there is no diff to show. + if (output.staged === true) return next(e) + + const diff = splitRows(output.structuredPatch) + // No hunks (a new file, an identical write): leave Claude Code's drawing alone. + if (diff === null) return next(e) + + const kit: Kit = $.ui.resolve(e) + const { Box, Text } = kit + const path = typeof output.filePath === 'string' ? output.filePath : undefined + + const shown = diff.rows.slice(0, MAX_ROWS) + const hidden = diff.rows.length - shown.length + + return Box({ + flexDirection: 'column', + children: [ + // The built-in summary line, with its response marker. + Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [' ⎿ '] }), + ...summary(Text, diff.added, diff.removed), + ], + }), + // The rows sit in a frame under the summary text, as the built-in diff does. + Box({ + flexDirection: 'column', + marginLeft: 5, + borderStyle: 'round', + borderDimColor: true, + children: [ + ...shown.map(row => drawRow(kit, row, path)), + hidden > 0 ? Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null, + ], + }), + ], + }) + }) +} + +function drawRow(kit: Kit, row: SplitRow, path: string | undefined) { + const { Box, Text } = kit + if (row.kind === 'gap') { + // The label sits in both columns, so a wide screen doesn't leave the right one blank. + const label = () => Text({ dimColor: true, children: ['⋯ ' + row.hidden + ' unchanged lines'] }) + // A blank line above and below sets the label apart; an empty 1-wide Box takes the separator's place so the columns line up. + const column = { width: '50%', flexShrink: 1, paddingY: 1 } as const + return Box({ + flexDirection: 'row', + children: [Box({ ...column, children: [label()] }), Box({ width: 1, flexShrink: 0 }), Box({ ...column, children: [label()] })], + }) + } + if (row.changed && row.left !== null && row.right !== null) { + return drawPair(kit, row.left, row.right, path) + } + return Box({ + flexDirection: 'row', + alignItems: 'stretch', + children: [ + drawCell(kit, row.left, row.changed ? 'removed' : 'same', path), + separator(kit), + drawCell(kit, row.right, row.changed ? 'added' : 'same', path), + ], + }) +} + +function drawCell( + { Box, Text, Code }: Kit, + cell: Cell | null, + kind: 'removed' | 'added' | 'same', + path: string | undefined, +) { + const column = { width: '50%', flexShrink: 1, flexDirection: 'row' } as const + + // Nothing on this side of the row: keep the column, draw nothing in it. + if (cell === null) return Box({ ...column, children: [Text({ children: [' '] })] }) + + // One-line hunk through the engine's diff renderer, so gutter, marker and + // add/remove colors are exactly the built-in diff's. + const n = cell.no + const header = + kind === 'removed' ? `@@ -${n},1 +${n},0 @@` : kind === 'added' ? `@@ -${n},0 +${n},1 @@` : `@@ -${n},1 +${n},1 @@` + const sign = kind === 'removed' ? '-' : kind === 'added' ? '+' : ' ' + + return Box({ + ...column, + children: [ + Code({ + source: header + '\n' + sign + cell.text, + format: 'diff', + wrap: 'wrap', + ...(path === undefined ? {} : { path }), + }), + ], + }) +} + +/** + * A removed line next to the line that replaced it. The engine draws the two as + * one hunk, which is what makes it tint the words that changed; each column + * then shows its own line of that hunk. Long lines are cut with an ellipsis, as + * a column clipped to one row cannot wrap. + */ +function drawPair(kit: Kit, left: Cell, right: Cell, path: string | undefined) { + const { Box, Code } = kit + const hunk = () => + Code({ + source: `@@ -${left.no},1 +${right.no},1 @@\n-${left.text}\n+${right.text}`, + format: 'diff', + wrap: 'truncate-end', + ...(path === undefined ? {} : { path }), + }) + const column = { width: '50%', flexShrink: 1, height: 1, overflow: 'hidden' } as const + return Box({ + flexDirection: 'row', + children: [ + Box({ ...column, children: [hunk()] }), + separator(kit), + Box({ + ...column, + children: [Box({ position: 'absolute', top: -1, left: 0, right: 0, children: [hunk()] })], + }), + ], + }) +} + +/** + * A dim vertical rule between the columns. It stretches to the row's height + * (wrapped lines make rows taller), so it draws a tall line out of flow and + * lets the clipped box cut it to the row. + */ +function separator({ Box, Text }: Kit) { + return Box({ + width: 1, + flexShrink: 0, + overflow: 'hidden', + children: [ + Box({ + position: 'absolute', + top: 0, + bottom: 0, + flexDirection: 'column', + children: Array.from({ length: 20 }, () => Text({ dimColor: true, children: ['│'] })), + }), + ], + }) +} + +/** "Added 3 lines, removed 1 line", the counts bold, as the built-in diff words it. */ +function summary(Text: Kit['Text'], added: number, removed: number) { + const noun = (n: number) => (n > 1 ? ' lines' : ' line') + const parts: ReturnType[] = [] + if (added > 0) { + parts.push(Text({ children: ['Added '] }), Text({ bold: true, children: [String(added)] })) + parts.push(Text({ children: [noun(added) + (removed > 0 ? ', ' : '')] })) + } + if (removed > 0) { + parts.push(Text({ children: [added === 0 ? 'Removed ' : 'removed '] })) + parts.push(Text({ bold: true, children: [String(removed)] }), Text({ children: [noun(removed)] })) + } + return parts +} diff --git a/hooks/split-rows.ts b/hooks/split-rows.ts new file mode 100644 index 0000000..a1ff59a --- /dev/null +++ b/hooks/split-rows.ts @@ -0,0 +1,140 @@ +/** One side of a split row: the line's number in its file and its text. */ +export type Cell = { no: number; text: string } + +export type SplitRow = + | { + kind: 'row' + /** The removed (or unchanged) line; null when this row only adds. */ + left: Cell | null + /** The added (or unchanged) line; null when this row only removes. */ + right: Cell | null + changed: boolean + } + | { kind: 'gap'; hidden: number } + +export type SplitDiff = { + rows: SplitRow[] + added: number + removed: number + /** Digits of the largest line number, for the number gutters. */ + numberWidth: number +} + +type Hunk = { oldStart: number; newStart: number; lines: string[] } + +const TAB = ' ' + +/** The most characters the engine takes in one drawn leaf. */ +const MAX_TEXT = 10_000 + +/** + * A line made safe to draw: tabs laid out as spaces, every control, format and + * invisible character dropped (a stray '\r' from a CRLF file would otherwise + * get the whole tree refused), and cut to the engine's cap. + */ +const drawable = (text: string) => + text + .replace(/\t/g, TAB) + .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '') + .slice(0, MAX_TEXT) + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** A tool result's `structuredPatch`, keeping only entries shaped like a hunk. */ +const hunksOf = (patch: unknown): Hunk[] => + (Array.isArray(patch) ? patch : []).flatMap((hunk: unknown): Hunk[] => { + if (!isRecord(hunk)) return [] + const { oldStart, newStart, lines } = hunk + if (typeof oldStart !== 'number' || typeof newStart !== 'number') return [] + if (!Array.isArray(lines)) return [] + return [ + { + oldStart, + newStart, + lines: lines.filter((line): line is string => typeof line === 'string'), + }, + ] + }) + +/** + * Turns a tool result's `structuredPatch` (unified hunks whose lines start with + * ' ', '-' or '+') into side-by-side rows. Each run of removed lines is paired + * with the added lines that follow it. A side that is null has no line in that + * row; a cell with empty text is a real blank line. + * + * @returns null when the patch holds no hunks, or when one side lacks a + * trailing newline the other has (the default renderer shows that marker) + */ +export function splitRows(patch: unknown): SplitDiff | null { + const hunks = hunksOf(patch) + if (hunks.length === 0) return null + + const rows: SplitRow[] = [] + let added = 0 + let removed = 0 + let largest = 0 + let previousOldEnd: number | null = null + + for (const hunk of hunks) { + if (previousOldEnd !== null) { + const hidden = hunk.oldStart - previousOldEnd + if (hidden > 0) rows.push({ kind: 'gap', hidden }) + } + + let oldNo = hunk.oldStart + let newNo = hunk.newStart + let dels: Cell[] = [] + let adds: Cell[] = [] + let previous = '' + let oldLacksNewline = false + let newLacksNewline = false + const flush = () => { + const count = Math.max(dels.length, adds.length) + for (let i = 0; i < count; i++) { + rows.push({ + kind: 'row', + left: dels[i] ?? null, + right: adds[i] ?? null, + changed: true, + }) + } + dels = [] + adds = [] + } + + for (const line of hunk.lines) { + const mark = line.charAt(0) + const text = drawable(line.slice(1)) + if (mark === '\\') { + // "\ No newline at end of file" refers to the line before it + if (previous !== '+') oldLacksNewline = true + if (previous !== '-') newLacksNewline = true + continue + } + previous = mark + if (mark === '-') { + dels.push({ no: oldNo++, text }) + removed++ + } else if (mark === '+') { + adds.push({ no: newNo++, text }) + added++ + } else { + flush() + rows.push({ + kind: 'row', + left: { no: oldNo++, text }, + right: { no: newNo++, text }, + changed: false, + }) + } + } + flush() + if (oldLacksNewline !== newLacksNewline) return null + + previousOldEnd = oldNo + largest = Math.max(largest, oldNo, newNo) + } + + return { rows, added, removed, numberWidth: String(largest).length } +} diff --git a/tests/split-rows.test.ts b/tests/split-rows.test.ts new file mode 100644 index 0000000..b8ac267 --- /dev/null +++ b/tests/split-rows.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'claude-code/testing' + +import { splitRows } from '../hooks/split-rows.js' + +const patch = [ + { + oldStart: 10, + oldLines: 4, + newStart: 10, + newLines: 5, + lines: [' keep', '-old one', '-old two', '+new one', '+new two', '+new three', ' tail'], + }, + { oldStart: 40, oldLines: 1, newStart: 41, newLines: 1, lines: ['-x', '+y'] }, +] + +test('pairs removed lines with the added lines that follow them', async () => { + const diff = splitRows(patch) + expect(diff).not.toBeNull() + expect(diff?.added).toBe(4) + expect(diff?.removed).toBe(3) + + const rows = diff?.rows ?? [] + // keep, (old one|new one), (old two|new two), (-|new three), tail, gap, (x|y) + expect(rows).toHaveLength(7) + expect(rows[1]).toEqual({ + kind: 'row', + left: { no: 11, text: 'old one' }, + right: { no: 11, text: 'new one' }, + changed: true, + }) + expect(rows[3]).toEqual({ + kind: 'row', + left: null, + right: { no: 13, text: 'new three' }, + changed: true, + }) +}) + +test('numbers lines from each hunk and reports the lines skipped between hunks', async () => { + const rows = splitRows(patch)?.rows ?? [] + expect(rows[5]).toEqual({ kind: 'gap', hidden: 26 }) + expect(rows[6]).toEqual({ + kind: 'row', + left: { no: 40, text: 'x' }, + right: { no: 41, text: 'y' }, + changed: true, + }) +}) + +test('returns null when there is nothing to draw', async () => { + expect(splitRows([])).toBeNull() + expect(splitRows(undefined)).toBeNull() + expect(splitRows([{ oldStart: 'a', newStart: 1, lines: [] }])).toBeNull() +}) + +test('lays tabs out as spaces and drops control characters', async () => { + const rows = splitRows([{ oldStart: 1, newStart: 1, lines: ['-a\tb\r', '+c\u200bd'] }])?.rows ?? [] + expect(rows[0]).toEqual({ + kind: 'row', + left: { no: 1, text: 'a b' }, + right: { no: 1, text: 'cd' }, + changed: true, + }) +}) + +test('returns null when only one side lacks a trailing newline', async () => { + const lines = ['-foo', '\\ No newline at end of file', '+foo'] + expect(splitRows([{ oldStart: 1, newStart: 1, lines }])).toBeNull() +}) + +test('keeps the split when both sides lack a trailing newline', async () => { + const lines = ['-foo', '\\ No newline at end of file', '+bar', '\\ No newline at end of file'] + expect(splitRows([{ oldStart: 1, newStart: 1, lines }])?.rows).toHaveLength(1) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8681ec0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.claude-plugin/types/tsconfig.json" +}