diff --git a/.windsurf/rules/specify-rules.md b/.windsurf/rules/specify-rules.md index e81b6cd..0967802 100644 --- a/.windsurf/rules/specify-rules.md +++ b/.windsurf/rules/specify-rules.md @@ -4,6 +4,9 @@ Auto-generated from all feature plans. Last updated: 2026-02-07 ## Active Technologies +- TypeScript 5, React 19 + `diff` npm package (diffChars, diffWords, diffLines), React hooks (003-diff-line-numbers) +- N/A (client-side only) (003-diff-line-numbers) + - TypeScript 5 (strict mode) + React 19, `diff` npm package (already installed — exports `diffChars`, `diffWords`, `diffLines`) (002-toggle-diff-options) - localStorage (browser-native, no new dependencies) (002-toggle-diff-options) @@ -29,9 +32,9 @@ TypeScript 5.9.3 (strict mode): Follow standard conventions ## Recent Changes -- 002-toggle-diff-options: Added TypeScript 5 (strict mode) + React 19, `diff` npm package (already installed — exports `diffChars`, `diffWords`, `diffLines`) +- 003-diff-line-numbers: Added TypeScript 5, React 19 + `diff` npm package (diffChars, diffWords, diffLines), React hooks -- 001-text-diff: Added TypeScript 5.9.3 (strict mode) + React 19.2.4, `diff` (npm — to be added as runtime dependency) +- 002-toggle-diff-options: Added TypeScript 5 (strict mode) + React 19, `diff` npm package (already installed — exports `diffChars`, `diffWords`, `diffLines`) - 001-text-diff: Added TypeScript 5.9.3 (strict mode) + React 19.2.4, `diff` (npm — to be added as runtime dependency) diff --git a/specs/003-diff-line-numbers/checklists/requirements.md b/specs/003-diff-line-numbers/checklists/requirements.md new file mode 100644 index 0000000..f1257af --- /dev/null +++ b/specs/003-diff-line-numbers/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Diff Line Numbers + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All clarifications resolved. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/003-diff-line-numbers/data-model.md b/specs/003-diff-line-numbers/data-model.md new file mode 100644 index 0000000..3fc217e --- /dev/null +++ b/specs/003-diff-line-numbers/data-model.md @@ -0,0 +1,100 @@ +# Data Model: Diff Line Numbers + +**Feature**: 003-diff-line-numbers +**Date**: 2026-02-08 + +## New Types + +### DiffLine + +A single line of diff output with line number metadata. + +```typescript +/** A single line in the diff output with line number metadata */ +export interface DiffLine { + /** The text content of this line (without trailing newline) */ + text: string; + /** The diff classification: added, removed, or unchanged */ + type: DiffType; + /** Line number in the original text, undefined for added lines */ + originalLineNumber: number | undefined; + /** Line number in the modified text, undefined for removed lines */ + modifiedLineNumber: number | undefined; +} +``` + +### DiffLineResult + +Extended diff result that includes line-based output alongside the existing segment-based output. + +```typescript +/** Extended diff result with line-based output for rendering with line numbers */ +export interface DiffLineResult extends DiffResult { + /** Line-based representation of the diff, derived from segments */ + lines: DiffLine[]; +} +``` + +## Modified Types + +### DiffResult (unchanged) + +The existing `DiffResult` interface is not modified. `DiffLineResult` extends it to maintain backward compatibility. + +### DiffViewerProps (modified) + +```typescript +export interface DiffViewerProps { + /** The computed diff result with line data, null when output should be hidden */ + result: DiffLineResult | null; + /** The effective display mode (forced 'unified' on mobile) */ + viewMode: ViewMode; +} +``` + +## Utility Functions + +### segmentsToLines + +Pure transformation function: `DiffSegment[] → DiffLine[]` + +**Input**: `DiffSegment[]` — flat array of diff segments from `useDiff` +**Output**: `DiffLine[]` — one entry per output line with line numbers + +**Algorithm**: + +1. Track two counters: `originalLine` (starts at 1), `modifiedLine` (starts at 1) +2. For each segment, split `value` by `\n` +3. For each resulting sub-line (skip trailing empty from split): + - Create `DiffLine` with appropriate line numbers based on type + - Increment counters: `removed` → originalLine++, `added` → modifiedLine++, `unchanged` → both++ +4. Return accumulated `DiffLine[]` + +**Line number assignment rules**: + +| Segment Type | originalLineNumber | modifiedLineNumber | +| ------------ | ------------------------ | ------------------------ | +| `unchanged` | current original counter | current modified counter | +| `removed` | current original counter | `undefined` | +| `added` | `undefined` | current modified counter | + +## Side-by-Side Pairing + +For side-by-side rendering, `DiffLine[]` is mapped into paired rows: + +```typescript +interface DiffRowPair { + original: DiffLine | null; + modified: DiffLine | null; +} +``` + +**Pairing rules**: + +- `unchanged` line → appears in both `original` and `modified` +- `removed` line → `original` gets the line, `modified` gets `null` (placeholder) +- `added` line → `original` gets `null` (placeholder), `modified` gets the line + +## localStorage Schema + +No changes — this feature does not add any persisted state. diff --git a/specs/003-diff-line-numbers/plan.md b/specs/003-diff-line-numbers/plan.md new file mode 100644 index 0000000..b6c3471 --- /dev/null +++ b/specs/003-diff-line-numbers/plan.md @@ -0,0 +1,73 @@ +# Implementation Plan: Diff Line Numbers + +**Branch**: `003-diff-line-numbers` | **Date**: 2026-02-08 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/003-diff-line-numbers/spec.md` + +## Summary + +Add line number gutters to the diff output in both unified and side-by-side views. A new transformation layer converts flat `DiffSegment[]` into line-based `DiffLine[]` with original/modified line numbers. The `DiffViewer` component is restructured from inline spans to a row-based table layout with a two-column gutter (original | modified) in unified view and a single-column gutter per side in side-by-side view. Line numbers appear for all diff methods by splitting non-line segments at newline boundaries. The gutter reuses the existing `TextInput` gutter style for visual consistency. + +## Technical Context + +**Language/Version**: TypeScript 5, React 19 +**Primary Dependencies**: `diff` npm package (diffChars, diffWords, diffLines), React hooks +**Storage**: N/A (client-side only) +**Testing**: Vitest 4 with @testing-library/react and @testing-library/user-event +**Target Platform**: Browser SPA, static hosting +**Project Type**: Single-page React app +**Performance Goals**: Instant recomputation on input change; line splitting adds negligible overhead +**Constraints**: No new runtime dependencies, client-side only, 100% test coverage +**Scale/Scope**: Typical diff inputs (1–10,000 lines) + +## Constitution Check + +_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ + +| Principle | Status | Notes | +| ----------------------------- | ------- | --------------------------------------------------------------------------------------- | +| I. Client-Side Only | ✅ PASS | All logic runs in browser, no server calls | +| II. Full Test Coverage | ✅ PASS | 100% coverage required for all new/modified code | +| III. Accessibility First | ✅ PASS | Gutter is `aria-hidden`, diff content uses semantic markup, color is not sole indicator | +| IV. Type Safety | ✅ PASS | New `DiffLine` interface with explicit types, strict mode | +| V. Simplicity and Performance | ✅ PASS | No new dependencies, pure transformation function, reuses existing patterns | + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-diff-line-numbers/ +├── plan.md # This file +├── research.md # Phase 0: line-splitting algorithm research +├── data-model.md # Phase 1: DiffLine type, segmentsToLines transform +├── quickstart.md # Phase 1: setup and development guide +└── tasks.md # Phase 2: task breakdown (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +src/ +├── types/ +│ └── diff.ts # MODIFY: add DiffLine interface +├── hooks/ +│ ├── useDiff.ts # MODIFY: add line-based output +│ └── useDiff.test.ts # MODIFY: add line number tests +├── utils/ +│ ├── segmentsToLines.ts # NEW: transform DiffSegment[] → DiffLine[] +│ └── segmentsToLines.test.ts # NEW: unit tests for transformation +├── components/ +│ ├── DiffViewer/ +│ │ ├── DiffViewer.tsx # MODIFY: row-based rendering with gutters +│ │ ├── DiffViewer.types.ts # MODIFY: accept DiffLine[] in props +│ │ └── DiffViewer.test.tsx # MODIFY: add line number tests +│ └── App/ +│ ├── App.tsx # MODIFY: pass lines to DiffViewer +│ └── App.test.tsx # MODIFY: integration tests for line numbers +``` + +**Structure Decision**: Single React SPA. New utility function in `src/utils/` for the pure transformation logic. No new component directories — line numbers are rendered within the existing `DiffViewer` component. + +## Complexity Tracking + +No constitution violations. No complexity justifications needed. diff --git a/specs/003-diff-line-numbers/quickstart.md b/specs/003-diff-line-numbers/quickstart.md new file mode 100644 index 0000000..e36fcf1 --- /dev/null +++ b/specs/003-diff-line-numbers/quickstart.md @@ -0,0 +1,72 @@ +# Quickstart: Diff Line Numbers + +**Feature**: 003-diff-line-numbers +**Date**: 2026-02-08 + +## Setup + +```bash +git checkout 003-diff-line-numbers +npm install +``` + +## Development Commands + +| Command | Purpose | +| -------------------------- | ----------------------------------- | +| `npm start` | Dev server at http://localhost:5173 | +| `npm run lint` | Run ESLint | +| `npm run lint:fix` | Auto-fix ESLint errors | +| `npm run lint:tsc` | TypeScript type checking | +| `npm test -- path/to/test` | Run a single test file | +| `npm run test:ci` | Run all tests with 100% coverage | +| `npm run build` | Production build | + +## Quality Gates (must all pass) + +```bash +npm run lint +npm run lint:tsc +npm run test:ci +npm run build +``` + +## Key Files + +### New Files + +| File | Purpose | +| ----------------------------------- | ----------------------------------------- | +| `src/utils/segmentsToLines.ts` | Pure function: DiffSegment[] → DiffLine[] | +| `src/utils/segmentsToLines.test.ts` | Unit tests for segmentsToLines | + +### Modified Files + +| File | Change | +| ----------------------------------------------- | ------------------------------------------------ | +| `src/types/diff.ts` | Add `DiffLine` and `DiffLineResult` interfaces | +| `src/hooks/useDiff.ts` | Return `DiffLineResult` (includes `lines` array) | +| `src/hooks/useDiff.test.ts` | Add tests for line number output | +| `src/components/DiffViewer/DiffViewer.tsx` | Row-based rendering with line number gutters | +| `src/components/DiffViewer/DiffViewer.types.ts` | Update props to accept `DiffLineResult` | +| `src/components/DiffViewer/DiffViewer.test.tsx` | Add line number rendering tests | +| `src/components/App/App.tsx` | Pass updated result to DiffViewer | +| `src/components/App/App.test.tsx` | Integration tests for line numbers | + +## Implementation Order + +1. **Add types** — `DiffLine` and `DiffLineResult` in `src/types/diff.ts` +2. **Implement `segmentsToLines`** — pure utility + tests in `src/utils/` +3. **Update `useDiff`** — return `DiffLineResult` with `lines` field + tests +4. **Update `DiffViewer`** — row-based rendering with gutters + tests +5. **Update `App`** — wire updated result type + integration tests +6. **Run all quality gates** + +## Independent Test + +1. Paste two multi-line texts with known differences +2. View unified diff — verify two-column gutter (original | modified line numbers) +3. Switch to side-by-side view — verify each column has its own gutter +4. Toggle between Characters/Words/Lines diff methods — verify line numbers remain correct +5. Try single-line input — verify gutter shows line 1 +6. Try asymmetric inputs (one much longer) — verify counters increment independently diff --git a/specs/003-diff-line-numbers/research.md b/specs/003-diff-line-numbers/research.md new file mode 100644 index 0000000..01d8041 --- /dev/null +++ b/specs/003-diff-line-numbers/research.md @@ -0,0 +1,102 @@ +# Research: Diff Line Numbers + +**Feature**: 003-diff-line-numbers +**Date**: 2026-02-08 + +## R1: Splitting DiffSegments into Lines + +**Decision**: Implement a pure utility function `segmentsToLines` that iterates through `DiffSegment[]`, splits each segment's `value` by `\n`, and emits one `DiffLine` per output line with tracked original/modified line counters. + +**Rationale**: The `diff` library returns segments as contiguous text chunks (e.g., `"line1\nline2\n"` as a single segment). To display line numbers, we must split these into discrete rows. A pure function is testable in isolation, memoizable, and keeps the hook layer thin. + +**Algorithm**: + +1. Initialize `originalLine = 1`, `modifiedLine = 1` +2. For each segment in `DiffSegment[]`: + - Split `segment.value` by `\n` + - For each sub-line (except trailing empty string from split): + - Emit a `DiffLine` with: + - `text`: the sub-line content + - `type`: inherited from the segment + - `originalLineNumber`: current counter if type is `removed` or `unchanged`, else `undefined` + - `modifiedLineNumber`: current counter if type is `added` or `unchanged`, else `undefined` + - Increment the appropriate counter(s) + - Handle mid-segment newlines: when a segment contains `\n`, each split piece becomes its own `DiffLine` +3. Special case: if a segment ends with `\n`, the final split produces an empty string — skip it (it represents the newline itself, not a new line of content) + +**Alternatives considered**: + +- **Modify `useDiff` to return lines directly**: Rejected — mixes concerns. The hook computes diffs; line splitting is a presentation transformation. +- **Split in the component**: Rejected — duplicates logic between unified and side-by-side renderers. +- **Use `diffLines` exclusively**: Rejected — spec requires line numbers for all three diff methods. + +## R2: DiffViewer Row-Based Rendering + +**Decision**: Restructure `DiffViewer` from inline `` elements to a table-like layout using `
` rows. Each row contains a gutter cell (line numbers) and a content cell (diff text with color coding). + +**Rationale**: Line numbers require vertical alignment between the gutter and content. A row-based layout naturally enforces this. Using `
` with CSS grid or flexbox (via Tailwind) keeps the markup semantic and avoids `` accessibility concerns. + +**Unified view layout**: + +``` +[orig#] [mod#] | content +``` + +- Two narrow gutter columns (original, modified) on the left +- Content column fills remaining width +- Removed lines: orig# shown, mod# blank +- Added lines: orig# blank, mod# shown +- Unchanged lines: both shown + +**Side-by-side view layout**: + +``` +[orig#] | original content || [mod#] | modified content +``` + +- Each column has its own single gutter +- Placeholder rows (faint gray background, no line number) for lines that don't exist on that side + +**Alternatives considered**: + +- **HTML `
`**: Rejected — adds accessibility complexity (need `role` overrides), Tailwind styling is less ergonomic with tables. +- **CSS `display: table`**: Rejected — same issues as `
`, less flexible for responsive behavior. +- **Keep inline spans, add line numbers via CSS counters**: Rejected — CSS counters can't produce two independent counters (original + modified) that skip based on diff type. + +## R3: Gutter Styling (Reuse TextInput Pattern) + +**Decision**: Reuse the existing `TextInput` gutter Tailwind classes for the diff output gutter. + +**Rationale**: Spec clarification Q2 explicitly requires visual consistency with `TextInput`. + +**Existing TextInput gutter classes**: + +- `bg-gray-50 dark:bg-gray-800` — light background strip +- `px-2 py-2` — padding +- `text-right` — right-aligned numbers +- `font-mono text-sm leading-6` — monospace, small, consistent line height +- `text-gray-400 dark:text-gray-500` — muted color +- `select-none` — not selectable +- `aria-hidden="true"` — hidden from screen readers + +**Diff gutter adaptation**: + +- Same base classes for each gutter column +- Unified view: two adjacent gutter columns, each with `min-w-[2ch]` to accommodate varying digit widths (FR-008) +- Side-by-side view: single gutter column per side + +## R4: Side-by-Side Placeholder Rows + +**Decision**: When a line exists only on one side (added or removed), the opposite column renders a placeholder row with a faint gray background (`bg-gray-100 dark:bg-gray-800`) and no line number. + +**Rationale**: Spec clarification Q4 requires GitHub convention. This keeps rows aligned across columns so users can visually track corresponding lines. + +**Implementation approach**: + +- The `segmentsToLines` output is a flat array. For side-by-side rendering, the component pairs lines: unchanged lines appear in both columns, removed lines appear only in original (placeholder in modified), added lines appear only in modified (placeholder in original). +- A helper function or inline logic in the component maps `DiffLine[]` into paired rows: `{ original: DiffLine | null, modified: DiffLine | null }[]`. + +**Alternatives considered**: + +- **No placeholders (skip rows)**: Rejected — spec explicitly requires aligned rows. +- **Placeholder with line number showing "—"**: Rejected — GitHub convention shows empty gutter, not a dash. diff --git a/specs/003-diff-line-numbers/spec.md b/specs/003-diff-line-numbers/spec.md new file mode 100644 index 0000000..8aa628d --- /dev/null +++ b/specs/003-diff-line-numbers/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Diff Line Numbers + +**Feature Branch**: `003-diff-line-numbers` +**Created**: 2026-02-08 +**Status**: Draft +**Input**: User description: "diff line numbers" + +## Clarifications + +- Q: Should line numbers appear for all diff methods or only when the method is "lines"? → A: Show line numbers for all three diff methods (characters, words, lines). The system must split word/character-level segments by newlines to reconstruct line-based rows so line numbers are always visible. +- Q: Should the diff output gutter reuse the existing TextInput gutter design pattern? → A: Yes — reuse the existing TextInput gutter style (muted gray, right-aligned, monospace, light background strip) for visual consistency between input and output areas. +- Q: How should the unified view gutter lay out two line numbers (original + modified)? → A: Two narrow columns side by side — left column for original line number, right column for modified line number (GitHub/GitLab convention). +- Q: What should placeholder rows look like in side-by-side view when a line doesn't exist on one side? → A: Follow GitHub convention — empty row with a subtle background tint (faint gray) to indicate the missing line, no line number shown. + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Line Numbers in Unified Diff View (Priority: P1) + +As a user comparing two texts, I want to see line numbers in the unified diff output so I can quickly reference specific lines when discussing changes. + +When the diff output is displayed in unified view, each line of the output shows a gutter on the left with the corresponding line number from the original text and the modified text. This helps orient the user within large diffs. + +**Why this priority**: Line numbers are the core value of this feature. The unified view is the default and most-used view mode, so it delivers the highest impact. + +**Independent Test**: Paste two multi-line texts with known differences, view the unified diff, and verify that each line in the output has correct line numbers from both the original and modified sources. + +**Acceptance Scenarios**: + +1. **Given** two multi-line texts with differences, **When** the diff is displayed in unified view, **Then** each output line shows the original line number and modified line number in a gutter +2. **Given** a line that exists only in the original (removed), **When** viewing the unified diff, **Then** the gutter shows the original line number and no modified line number +3. **Given** a line that exists only in the modified text (added), **When** viewing the unified diff, **Then** the gutter shows no original line number and the modified line number +4. **Given** an unchanged line, **When** viewing the unified diff, **Then** the gutter shows both the original and modified line numbers +5. **Given** two identical texts, **When** viewing the diff, **Then** the "No differences found" message is shown without any line numbers + +--- + +### User Story 2 - Line Numbers in Side-by-Side Diff View (Priority: P2) + +As a user comparing two texts in side-by-side mode, I want to see line numbers in each column so I can track which line I'm looking at in each version. + +When the diff output is displayed in side-by-side view, the original column shows line numbers from the original text and the modified column shows line numbers from the modified text. + +**Why this priority**: Extends line number support to the secondary view mode. Depends on the same underlying line-splitting logic as P1. + +**Independent Test**: Paste two multi-line texts, switch to side-by-side view on desktop, and verify each column displays correct line numbers. + +**Acceptance Scenarios**: + +1. **Given** two multi-line texts with differences in side-by-side view, **When** the diff is displayed, **Then** the original column shows original line numbers and the modified column shows modified line numbers +2. **Given** a removed line in side-by-side view, **When** viewing the diff, **Then** the original column shows the line with its number and the modified column shows a blank placeholder row +3. **Given** an added line in side-by-side view, **When** viewing the diff, **Then** the modified column shows the line with its number and the original column shows a blank placeholder row + +--- + +### Edge Cases + +- What happens when the diff method is set to "characters" or "words" (non-line-based)? The system splits word/character segments by newlines to reconstruct line-based rows, so line numbers are always visible regardless of diff method. +- What happens when the text has no newlines (single-line input)? The gutter should show line 1 for both sides. +- What happens when one text is much longer than the other? Line numbers should continue incrementing correctly for each side independently. +- What happens with very large line numbers (e.g., 10,000+ lines)? The gutter width should accommodate the number of digits without clipping. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: The diff output MUST display line numbers in a gutter alongside each line of diff content in unified view +- **FR-002**: The diff output MUST display line numbers in a gutter alongside each line of diff content in side-by-side view +- **FR-003**: Removed lines MUST show the original line number but no modified line number +- **FR-004**: Added lines MUST show the modified line number but no original line number +- **FR-005**: Unchanged lines MUST show both original and modified line numbers +- **FR-006**: Line numbers MUST reuse the existing TextInput gutter style — muted gray text, right-aligned, monospace font, light background strip — for visual consistency +- **FR-007**: The line number gutter MUST be decorative and hidden from screen readers (aria-hidden) +- **FR-008**: The gutter MUST accommodate varying digit widths without clipping or layout shifts +- **FR-009**: Line numbers MUST be correct when the diff method is "lines" — each segment maps directly to source lines +- **FR-010**: Line numbers MUST be displayed for all diff methods (characters, words, lines) — the system MUST split non-line segments by newlines to reconstruct line-based rows +- **FR-011**: In unified view, the gutter MUST display two side-by-side columns — left for original line number, right for modified line number (GitHub/GitLab convention) +- **FR-012**: In side-by-side view, placeholder rows for missing lines MUST display an empty row with a subtle background tint (faint gray) and no line number, following GitHub convention + +### Key Entities + +- **DiffLine**: A single line of diff output, containing the text content, diff type (added/removed/unchanged), original line number (if applicable), and modified line number (if applicable) + +## Assumptions + +- The diff output currently renders segments as inline spans. To support line numbers, the rendering will need to restructure output into discrete rows (one per line). +- The `DiffSegment` type or a new derived type will need to carry line number metadata. +- The `useDiff` hook or a new transformation layer will need to compute line numbers from the raw diff segments. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Users can identify the exact line number of any change in the diff output within 2 seconds of viewing +- **SC-002**: Line numbers are correct for all three diff methods (characters, words, lines) when the output contains newlines +- **SC-003**: 100% test coverage maintained across all new and modified code +- **SC-004**: All existing quality gates pass (lint, type check, tests, build) +- **SC-005**: Line number gutter does not interfere with screen reader accessibility (aria-hidden) diff --git a/specs/003-diff-line-numbers/tasks.md b/specs/003-diff-line-numbers/tasks.md new file mode 100644 index 0000000..20db544 --- /dev/null +++ b/specs/003-diff-line-numbers/tasks.md @@ -0,0 +1,146 @@ +# Tasks: Diff Line Numbers + +**Input**: Design documents from `/specs/003-diff-line-numbers/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, quickstart.md + +**Tests**: Included — constitution mandates 100% test coverage (`npm run test:ci`). + +**Organization**: Tasks are grouped by phase. US1 (unified view) is the MVP. US2 (side-by-side view) builds on the same infrastructure. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Add the `DiffLine` and `DiffLineResult` types and the pure `segmentsToLines` utility that all subsequent tasks depend on. + +- [x] T001 Add `DiffLine` interface and `DiffLineResult` interface (extends `DiffResult`) to src/types/diff.ts +- [x] T002 [P] Write unit tests for `segmentsToLines` utility in src/utils/segmentsToLines.test.ts (unchanged lines, removed lines, added lines, mixed segments, multi-line segments split by `\n`, single-line input, empty segments, segments ending with newline) +- [x] T003 [P] Implement `segmentsToLines` pure function in src/utils/segmentsToLines.ts (iterate segments, split by `\n`, track original/modified line counters, emit DiffLine per output line) + +**Checkpoint**: `DiffLine` type exists, `segmentsToLines` is tested and working. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Update `useDiff` to return `DiffLineResult` with a `lines` field computed via `segmentsToLines`. This MUST be complete before the UI can render line numbers. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T004 Update `useDiff` hook to call `segmentsToLines` and return `DiffLineResult` (with `lines` field) in src/hooks/useDiff.ts +- [x] T005 Update `useDiff` tests to verify `lines` output includes correct line numbers for all three diff methods in src/hooks/useDiff.test.ts +- [x] T006 Update `DiffViewerProps` to accept `DiffLineResult` instead of `DiffResult` in src/components/DiffViewer/DiffViewer.types.ts + +**Checkpoint**: `useDiff` returns line-based output with correct line numbers, `DiffViewer` types updated. + +--- + +## Phase 3: User Story 1 — Line Numbers in Unified View (Priority: P1) 🎯 MVP + +**Goal**: User sees a two-column line number gutter (original | modified) alongside each line of diff content in unified view. + +**Independent Test**: Paste two multi-line texts with known differences, view the unified diff, and verify each line shows correct original/modified line numbers in the gutter. Removed lines show only original number, added lines show only modified number, unchanged lines show both. + +### Tests for User Story 1 + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [x] T007 [P] [US1] Write unit tests for unified view line number rendering in src/components/DiffViewer/DiffViewer.test.tsx (gutter renders two columns with correct line numbers, removed lines show original number only, added lines show modified number only, unchanged lines show both, gutter is aria-hidden, gutter uses TextInput gutter styling classes) +- [x] T008 [P] [US1] Write integration tests for unified view line numbers in src/components/App/App.test.tsx (line numbers appear when diff is displayed, line numbers correct across diff method changes) + +### Implementation for User Story 1 + +- [x] T009 [US1] Restructure unified view rendering in src/components/DiffViewer/DiffViewer.tsx from inline spans to row-based layout with two-column gutter (original | modified) using TextInput gutter style classes (`bg-gray-50 dark:bg-gray-800`, `text-right font-mono text-sm leading-6 text-gray-400 dark:text-gray-500`, `select-none`, `aria-hidden="true"`) +- [x] T010 [US1] Update App component to pass `DiffLineResult` to `DiffViewer` in src/components/App/App.tsx (type change only — `useDiff` already returns the new type after T004) + +**Checkpoint**: User Story 1 is fully functional — unified view shows line number gutters, all tests pass. + +--- + +## Phase 4: User Story 2 — Line Numbers in Side-by-Side View (Priority: P2) + +**Goal**: User sees line numbers in each column of the side-by-side view, with placeholder rows (faint gray background) for lines that don't exist on one side. + +**Independent Test**: Paste two multi-line texts, switch to side-by-side view on desktop, and verify each column has its own line number gutter. Removed lines show a placeholder in the modified column, added lines show a placeholder in the original column. + +### Tests for User Story 2 + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [x] T011 [P] [US2] Write unit tests for side-by-side line number rendering in src/components/DiffViewer/DiffViewer.test.tsx (each column has a gutter, original column shows original line numbers, modified column shows modified line numbers, placeholder rows have faint gray background and no line number, gutter is aria-hidden) +- [x] T012 [P] [US2] Write integration tests for side-by-side line numbers in src/components/App/App.test.tsx (line numbers appear in side-by-side mode, placeholder rows render correctly) + +### Implementation for User Story 2 + +- [x] T013 [US2] Restructure side-by-side view rendering in src/components/DiffViewer/DiffViewer.tsx from inline spans to row-based layout with single-column gutter per side, placeholder rows with `bg-gray-100 dark:bg-gray-800` for missing lines + +**Checkpoint**: User Stories 1 AND 2 both work independently — unified and side-by-side views both show line numbers. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: Final quality pass. + +- [x] T014 [P] Accessibility audit — verify `aria-hidden` on all gutters, keyboard tab order unaffected, screen reader does not announce line numbers +- [x] T015 Run all quality gates: `npm run lint`, `npm run lint:tsc`, `npm run test:ci`, `npm run build` +- [x] T016 Run quickstart.md validation — follow all steps in specs/003-diff-line-numbers/quickstart.md and verify they work end-to-end + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 (`DiffLine` type, `segmentsToLines`) — BLOCKS user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 (`useDiff` returning `DiffLineResult`) +- **User Story 2 (Phase 4)**: Depends on Phase 2 and shares rendering infrastructure with US1 (Phase 3) +- **Polish (Phase 5)**: Depends on all user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) — no dependencies on US2 +- **User Story 2 (P2)**: Should start after US1 (Phase 3) since both modify `DiffViewer.tsx` — avoids merge conflicts + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Type changes before component changes +- Component implementation before App integration + +### Parallel Opportunities + +- **Phase 1**: T002 (segmentsToLines tests) and T003 (segmentsToLines impl) can run in parallel after T001 +- **Phase 3**: T007 (DiffViewer tests) and T008 (App tests) can run in parallel +- **Phase 4**: T011 (DiffViewer tests) and T012 (App tests) can run in parallel + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (DiffLine type + segmentsToLines) +2. Complete Phase 2: Foundational (useDiff returns DiffLineResult) +3. Complete Phase 3: User Story 1 (unified view line numbers) +4. **STOP and VALIDATE**: Test unified view line numbers independently +5. Complete Phase 4: User Story 2 (side-by-side view line numbers) +6. Complete Phase 5: Polish & quality gates + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [US1]/[US2] labels map tasks to User Stories for traceability +- No new runtime dependencies — `segmentsToLines` is a pure TypeScript function +- Existing `DiffViewer` tests will need updating since the rendering structure changes from inline spans to rows +- The `+`/`-` prefix on added/removed segments is preserved in the new row-based layout +- `segmentsToLines` is placed in `src/utils/` (new directory) since it's a pure function, not a hook or component diff --git a/src/components/App/App.test.tsx b/src/components/App/App.test.tsx index 5e1bb93..c36a73f 100644 --- a/src/components/App/App.test.tsx +++ b/src/components/App/App.test.tsx @@ -427,4 +427,252 @@ describe('App component', () => { const columns = container.querySelectorAll('[data-testid^="diff-column-"]'); expect(columns).toHaveLength(2); }); + + it('shows line number gutter in unified diff view', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'line1\nline2'); + await user.type(modified, 'line1\nchanged'); + + const gutter = container.querySelector('[data-testid="diff-gutter"]'); + expect(gutter).toBeInTheDocument(); + expect(gutter?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('displays correct line numbers for multi-line diff', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'a\nb\n'); + await user.type(modified, 'a\nc\n'); + + await user.click(screen.getByRole('button', { name: 'Lines' })); + + const origCells = container.querySelectorAll( + '[data-testid="gutter-original"]', + ); + const modCells = container.querySelectorAll( + '[data-testid="gutter-modified"]', + ); + expect(origCells.length).toBeGreaterThan(0); + expect(modCells.length).toBeGreaterThan(0); + }); + + it('updates output when clearing one input after diff is displayed', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'hello'); + await user.type(modified, 'world'); + + expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument(); + + await user.clear(original); + + expect( + container.querySelector('[aria-live="polite"]'), + ).not.toBeInTheDocument(); + }); + + it('handles special characters and emoji correctly', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'café ☕'); + await user.type(modified, 'café 🍵'); + + const diffOutput = container.querySelector('[aria-live="polite"]'); + expect(diffOutput).toBeInTheDocument(); + + const addedSpan = diffOutput?.querySelector('.bg-green-100'); + expect(addedSpan).toBeInTheDocument(); + }); + + it('defaults to Words diff method', async () => { + const user = userEvent.setup(); + render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'hello'); + await user.type(modified, 'world'); + + const wordsButton = screen.getByRole('button', { name: 'Words' }); + expect(wordsButton.className).toContain('bg-blue-500'); + }); + + it('switches diff output when changing diff method', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'abc'); + await user.type(modified, 'aXc'); + + const diffOutput = container.querySelector('[aria-live="polite"]'); + + await user.click(screen.getByRole('button', { name: 'Characters' })); + + const removedSpan = diffOutput?.querySelector('.bg-red-100'); + expect(removedSpan).toBeInTheDocument(); + expect(removedSpan?.textContent).toBe('-b'); + + const addedSpan = diffOutput?.querySelector('.bg-green-100'); + expect(addedSpan).toBeInTheDocument(); + expect(addedSpan?.textContent).toBe('+X'); + }); + + it('persists diff method to localStorage', async () => { + const user = userEvent.setup(); + render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'hello'); + await user.type(modified, 'world'); + + await user.click(screen.getByRole('button', { name: 'Lines' })); + + expect(localStorage.getItem('diffMethod')).toBe(JSON.stringify('lines')); + }); + + it('restores diff method from localStorage on mount', async () => { + localStorage.setItem('diffMethod', JSON.stringify('characters')); + + const user = userEvent.setup(); + render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'hello'); + await user.type(modified, 'world'); + + const charsButton = screen.getByRole('button', { name: 'Characters' }); + expect(charsButton.className).toContain('bg-blue-500'); + }); + + it('persists view mode to localStorage', async () => { + const user = userEvent.setup(); + render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'hello'); + await user.type(modified, 'world'); + + await user.click(screen.getByRole('button', { name: /side-by-side/i })); + + expect(localStorage.getItem('viewMode')).toBe( + JSON.stringify('side-by-side'), + ); + }); + + it('restores view mode from localStorage on mount', async () => { + localStorage.setItem('viewMode', JSON.stringify('side-by-side')); + + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as typeof window.matchMedia; + + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'hello'); + await user.type(modified, 'world'); + + const columns = container.querySelectorAll('[data-testid^="diff-column-"]'); + expect(columns).toHaveLength(2); + }); + + it('shows line number gutter in unified diff view', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'line1\nline2'); + await user.type(modified, 'line1\nchanged'); + + const gutter = container.querySelector('[data-testid="diff-gutter"]'); + expect(gutter).toBeInTheDocument(); + expect(gutter?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('displays correct line numbers for multi-line diff', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'a\nb\n'); + await user.type(modified, 'a\nc\n'); + + await user.click(screen.getByRole('button', { name: 'Lines' })); + + const origCells = container.querySelectorAll( + '[data-testid="gutter-original"]', + ); + const modCells = container.querySelectorAll( + '[data-testid="gutter-modified"]', + ); + expect(origCells.length).toBeGreaterThan(0); + expect(modCells.length).toBeGreaterThan(0); + + expect(origCells[0].textContent).toBe('1'); + expect(modCells[0].textContent).toBe('1'); + }); + + it('shows line number gutters in side-by-side view', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as typeof window.matchMedia; + + const user = userEvent.setup(); + const { container } = render(); + + const original = screen.getByLabelText('Original Text'); + const modified = screen.getByLabelText('Modified Text'); + + await user.type(original, 'a\nb'); + await user.type(modified, 'a\nc'); + + await user.click(screen.getByRole('button', { name: 'Side-by-Side' })); + + const origGutter = container.querySelector( + '[data-testid="sbs-gutter-original"]', + ); + const modGutter = container.querySelector( + '[data-testid="sbs-gutter-modified"]', + ); + expect(origGutter).toBeInTheDocument(); + expect(modGutter).toBeInTheDocument(); + }); }); diff --git a/src/components/DiffViewer/DiffViewer.test.tsx b/src/components/DiffViewer/DiffViewer.test.tsx index f4d4482..0d254c3 100644 --- a/src/components/DiffViewer/DiffViewer.test.tsx +++ b/src/components/DiffViewer/DiffViewer.test.tsx @@ -1,8 +1,16 @@ import { render, screen } from '@testing-library/react'; -import type { DiffResult } from 'src/types/diff'; +import type { DiffLineResult } from 'src/types/diff'; +import { segmentsToLines } from 'src/utils/segmentsToLines'; import DiffViewer from '.'; +function makeResult( + segments: DiffLineResult['segments'], + hasChanges: boolean, +): DiffLineResult { + return { segments, hasChanges, lines: segmentsToLines(segments) }; +} + describe('DiffViewer component', () => { it('renders nothing when result is null', () => { const { container } = render( @@ -12,10 +20,7 @@ describe('DiffViewer component', () => { }); it('renders "No differences found" with role="status" when hasChanges is false', () => { - const result: DiffResult = { - segments: [{ value: 'hello', type: 'unchanged' }], - hasChanges: false, - }; + const result = makeResult([{ value: 'hello', type: 'unchanged' }], false); render(); @@ -24,59 +29,62 @@ describe('DiffViewer component', () => { }); it('renders added segments with green styling and + prefix', () => { - const result: DiffResult = { - segments: [{ value: 'added text', type: 'added' }], - hasChanges: true, - }; + const result = makeResult([{ value: 'added text', type: 'added' }], true); render(); const addedElement = screen.getByText(/added text/); expect(addedElement).toBeInTheDocument(); expect(addedElement.textContent).toContain('+'); - expect(addedElement.className).toContain('bg-green-100'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const row = addedElement.closest('div')!; + expect(row.className).toContain('bg-green-100'); }); it('renders removed segments with red styling and - prefix', () => { - const result: DiffResult = { - segments: [{ value: 'removed text', type: 'removed' }], - hasChanges: true, - }; + const result = makeResult( + [{ value: 'removed text', type: 'removed' }], + true, + ); render(); const removedElement = screen.getByText(/removed text/); expect(removedElement).toBeInTheDocument(); expect(removedElement.textContent).toContain('-'); - expect(removedElement.className).toContain('bg-red-100'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const row = removedElement.closest('div')!; + expect(row.className).toContain('bg-red-100'); }); it('renders unchanged segments without highlighting', () => { - const result: DiffResult = { - segments: [ + const result = makeResult( + [ { value: 'unchanged', type: 'unchanged' }, { value: 'added', type: 'added' }, ], - hasChanges: true, - }; + true, + ); render(); const unchangedElement = screen.getByText('unchanged'); expect(unchangedElement).toBeInTheDocument(); - expect(unchangedElement.className).not.toContain('bg-green'); - expect(unchangedElement.className).not.toContain('bg-red'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const row = unchangedElement.closest('div')!; + expect(row.className).not.toContain('bg-green'); + expect(row.className).not.toContain('bg-red'); }); it('renders unified view correctly', () => { - const result: DiffResult = { - segments: [ + const result = makeResult( + [ { value: 'hello ', type: 'unchanged' }, { value: 'world', type: 'removed' }, { value: 'there', type: 'added' }, ], - hasChanges: true, - }; + true, + ); const { container } = render( , @@ -89,14 +97,14 @@ describe('DiffViewer component', () => { }); it('renders side-by-side view correctly', () => { - const result: DiffResult = { - segments: [ + const result = makeResult( + [ { value: 'hello ', type: 'unchanged' }, { value: 'world', type: 'removed' }, { value: 'there', type: 'added' }, ], - hasChanges: true, - }; + true, + ); const { container } = render( , @@ -110,10 +118,7 @@ describe('DiffViewer component', () => { }); it('wraps output in aria-live="polite"', () => { - const result: DiffResult = { - segments: [{ value: 'test', type: 'unchanged' }], - hasChanges: false, - }; + const result = makeResult([{ value: 'test', type: 'unchanged' }], false); const { container } = render( , @@ -122,4 +127,185 @@ describe('DiffViewer component', () => { const liveRegion = container.querySelector('[aria-live="polite"]'); expect(liveRegion).toBeInTheDocument(); }); + + it('renders line number gutter in unified view', () => { + const result = makeResult( + [ + { value: 'line1\n', type: 'unchanged' }, + { value: 'old\n', type: 'removed' }, + { value: 'new\n', type: 'added' }, + ], + true, + ); + + const { container } = render( + , + ); + + const gutter = container.querySelector('[data-testid="diff-gutter"]'); + expect(gutter).toBeInTheDocument(); + expect(gutter?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('shows both line numbers for unchanged lines in unified view', () => { + const result = makeResult( + [ + { value: 'same\n', type: 'unchanged' }, + { value: 'extra\n', type: 'added' }, + ], + true, + ); + + const { container } = render( + , + ); + + const origCells = container.querySelectorAll( + '[data-testid="gutter-original"]', + ); + const modCells = container.querySelectorAll( + '[data-testid="gutter-modified"]', + ); + expect(origCells[0].textContent).toBe('1'); + expect(modCells[0].textContent).toBe('1'); + }); + + it('shows only original line number for removed lines', () => { + const result = makeResult([{ value: 'removed\n', type: 'removed' }], true); + + const { container } = render( + , + ); + + const origCells = container.querySelectorAll( + '[data-testid="gutter-original"]', + ); + const modCells = container.querySelectorAll( + '[data-testid="gutter-modified"]', + ); + expect(origCells[0].textContent).toBe('1'); + expect(modCells[0].textContent).toBe(''); + }); + + it('shows only modified line number for added lines', () => { + const result = makeResult([{ value: 'added\n', type: 'added' }], true); + + const { container } = render( + , + ); + + const origCells = container.querySelectorAll( + '[data-testid="gutter-original"]', + ); + const modCells = container.querySelectorAll( + '[data-testid="gutter-modified"]', + ); + expect(origCells[0].textContent).toBe(''); + expect(modCells[0].textContent).toBe('1'); + }); + + it('uses TextInput gutter styling classes', () => { + const result = makeResult( + [ + { value: 'line\n', type: 'unchanged' }, + { value: 'x\n', type: 'added' }, + ], + true, + ); + + const { container } = render( + , + ); + + const gutter = container.querySelector('[data-testid="diff-gutter"]'); + expect(gutter?.className).toContain('bg-gray-50'); + expect(gutter?.className).toContain('font-mono'); + expect(gutter?.className).toContain('select-none'); + }); + + it('renders line number gutters in side-by-side view', () => { + const result = makeResult( + [ + { value: 'same\n', type: 'unchanged' }, + { value: 'old\n', type: 'removed' }, + { value: 'new\n', type: 'added' }, + ], + true, + ); + + const { container } = render( + , + ); + + const origGutter = container.querySelector( + '[data-testid="sbs-gutter-original"]', + ); + const modGutter = container.querySelector( + '[data-testid="sbs-gutter-modified"]', + ); + expect(origGutter).toBeInTheDocument(); + expect(origGutter?.getAttribute('aria-hidden')).toBe('true'); + expect(modGutter).toBeInTheDocument(); + expect(modGutter?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('shows correct line numbers in side-by-side original column', () => { + const result = makeResult( + [ + { value: 'same\n', type: 'unchanged' }, + { value: 'old\n', type: 'removed' }, + ], + true, + ); + + const { container } = render( + , + ); + + const origNums = container.querySelectorAll( + '[data-testid="sbs-original-line"]', + ); + expect(origNums[0].textContent).toBe('1'); + expect(origNums[1].textContent).toBe('2'); + }); + + it('shows correct line numbers in side-by-side modified column', () => { + const result = makeResult( + [ + { value: 'same\n', type: 'unchanged' }, + { value: 'new\n', type: 'added' }, + ], + true, + ); + + const { container } = render( + , + ); + + const modNums = container.querySelectorAll( + '[data-testid="sbs-modified-line"]', + ); + expect(modNums[0].textContent).toBe('1'); + expect(modNums[1].textContent).toBe('2'); + }); + + it('renders placeholder rows for missing lines in side-by-side view', () => { + const result = makeResult( + [ + { value: 'old\n', type: 'removed' }, + { value: 'new\n', type: 'added' }, + ], + true, + ); + + const { container } = render( + , + ); + + const placeholders = container.querySelectorAll( + '[data-testid="sbs-placeholder"]', + ); + expect(placeholders.length).toBeGreaterThan(0); + expect(placeholders[0].className).toContain('bg-gray-100'); + }); }); diff --git a/src/components/DiffViewer/DiffViewer.tsx b/src/components/DiffViewer/DiffViewer.tsx index bc20785..08aa389 100644 --- a/src/components/DiffViewer/DiffViewer.tsx +++ b/src/components/DiffViewer/DiffViewer.tsx @@ -1,5 +1,26 @@ +import type { DiffLine } from 'src/types/diff'; + import type { DiffViewerProps } from './DiffViewer.types'; +interface DiffRowPair { + original: DiffLine | null; + modified: DiffLine | null; +} + +function pairLines(lines: DiffLine[]): DiffRowPair[] { + const pairs: DiffRowPair[] = []; + for (const line of lines) { + if (line.type === 'unchanged') { + pairs.push({ original: line, modified: line }); + } else if (line.type === 'removed') { + pairs.push({ original: line, modified: null }); + } else { + pairs.push({ original: null, modified: line }); + } + } + return pairs; +} + export default function DiffViewer({ result, viewMode }: DiffViewerProps) { if (!result) { return null; @@ -16,52 +37,106 @@ export default function DiffViewer({ result, viewMode }: DiffViewerProps) { } if (viewMode === 'side-by-side') { + const pairs = pairLines(result.lines); + return (
- {result.segments.map((segment) => { - const key = `orig-${segment.type}-${segment.value}`; - if (segment.type === 'removed') { - return ( - - -{segment.value} + +
+ {pairs.map((pair, i) => { + if (!pair.original) { + return ( +
+ {'\u00A0'} +
+ ); + } + if (pair.original.type === 'removed') { + return ( +
+ -{pair.original.text} +
+ ); + } + return ( +
+ {pair.original.text} +
); - } - if (segment.type === 'unchanged') { - return {segment.value}; - } - return null; - })} + })} +
- {result.segments.map((segment) => { - const key = `mod-${segment.type}-${segment.value}`; - if (segment.type === 'added') { - return ( - - +{segment.value} + +
+ {pairs.map((pair, i) => { + if (!pair.modified) { + return ( +
+ {'\u00A0'} +
+ ); + } + if (pair.modified.type === 'added') { + return ( +
+ +{pair.modified.text} +
+ ); + } + return ( +
+ {pair.modified.text} +
); - } - if (segment.type === 'unchanged') { - return {segment.value}; - } - return null; - })} + })} +
@@ -70,31 +145,62 @@ export default function DiffViewer({ result, viewMode }: DiffViewerProps) { return (
-
- {result.segments.map((segment) => { - const key = `${segment.type}-${segment.value}`; - if (segment.type === 'added') { +
+ +
+ {result.lines.map((line, i) => { + const key = `c-${String(i)}-${line.type}`; + if (line.type === 'added') { + return ( +
+ +{line.text} +
+ ); + } + if (line.type === 'removed') { + return ( +
+ -{line.text} +
+ ); + } return ( - - -{segment.value} - +
+ {line.text} +
); - } - return {segment.value}; - })} + })} +
); diff --git a/src/components/DiffViewer/DiffViewer.types.ts b/src/components/DiffViewer/DiffViewer.types.ts index 952680e..af25b5d 100644 --- a/src/components/DiffViewer/DiffViewer.types.ts +++ b/src/components/DiffViewer/DiffViewer.types.ts @@ -1,8 +1,8 @@ -import type { DiffResult, ViewMode } from 'src/types/diff'; +import type { DiffLineResult, ViewMode } from 'src/types/diff'; export interface DiffViewerProps { - /** The computed diff result, null when output should be hidden */ - result: DiffResult | null; + /** The computed diff result with line data, null when output should be hidden */ + result: DiffLineResult | null; /** The effective display mode (forced 'unified' on mobile) */ viewMode: ViewMode; } diff --git a/src/hooks/useDiff.test.ts b/src/hooks/useDiff.test.ts index 0ef7d6b..26465b6 100644 --- a/src/hooks/useDiff.test.ts +++ b/src/hooks/useDiff.test.ts @@ -116,4 +116,45 @@ describe('useDiff', () => { withoutMethod.result.current?.segments, ); }); + + it('includes lines array in result', () => { + const { result } = renderHook(() => useDiff('hello world', 'hello world')); + expect(result.current?.lines).toBeDefined(); + expect(result.current?.lines.length).toBeGreaterThan(0); + }); + + it('returns correct line numbers for line-level diff', () => { + const { result } = renderHook(() => + useDiff('line1\nline2\n', 'line1\nchanged\n', 'lines'), + ); + const lines = result.current?.lines ?? []; + + const unchanged = lines.filter((l) => l.type === 'unchanged'); + expect(unchanged[0]).toMatchObject({ + text: 'line1', + originalLineNumber: 1, + modifiedLineNumber: 1, + }); + + const removed = lines.filter((l) => l.type === 'removed'); + expect(removed[0]).toMatchObject({ + originalLineNumber: 2, + modifiedLineNumber: undefined, + }); + + const added = lines.filter((l) => l.type === 'added'); + expect(added[0]).toMatchObject({ + originalLineNumber: undefined, + modifiedLineNumber: 2, + }); + }); + + it('returns lines for character-level diff with newlines', () => { + const { result } = renderHook(() => + useDiff('a\nb\n', 'a\nc\n', 'characters'), + ); + const lines = result.current?.lines ?? []; + expect(lines.length).toBeGreaterThan(0); + expect(lines[0]?.originalLineNumber).toBe(1); + }); }); diff --git a/src/hooks/useDiff.ts b/src/hooks/useDiff.ts index 9c059d6..0ee672f 100644 --- a/src/hooks/useDiff.ts +++ b/src/hooks/useDiff.ts @@ -1,7 +1,8 @@ import type { Change } from 'diff'; import { diffChars, diffLines, diffWords } from 'diff'; import { useMemo } from 'react'; -import type { DiffMethod, DiffResult, DiffSegment } from 'src/types/diff'; +import type { DiffLineResult, DiffMethod, DiffSegment } from 'src/types/diff'; +import { segmentsToLines } from 'src/utils/segmentsToLines'; function computeChanges( method: DiffMethod, @@ -26,7 +27,7 @@ export function useDiff( originalText: string, modifiedText: string, method: DiffMethod = 'words', -): DiffResult | null { +): DiffLineResult | null { return useMemo(() => { if (!originalText || !modifiedText) { return null; @@ -43,6 +44,8 @@ export function useDiff( (segment) => segment.type === 'added' || segment.type === 'removed', ); - return { segments, hasChanges }; + const lines = segmentsToLines(segments); + + return { segments, hasChanges, lines }; }, [originalText, modifiedText, method]); } diff --git a/src/types/diff.ts b/src/types/diff.ts index 42ebad0..0a4058b 100644 --- a/src/types/diff.ts +++ b/src/types/diff.ts @@ -22,3 +22,21 @@ export type ViewMode = 'unified' | 'side-by-side'; /** Available diff comparison methods */ export type DiffMethod = 'characters' | 'words' | 'lines'; + +/** A single line in the diff output with line number metadata */ +export interface DiffLine { + /** The text content of this line (without trailing newline) */ + text: string; + /** The diff classification: added, removed, or unchanged */ + type: DiffType; + /** Line number in the original text, undefined for added lines */ + originalLineNumber: number | undefined; + /** Line number in the modified text, undefined for removed lines */ + modifiedLineNumber: number | undefined; +} + +/** Extended diff result with line-based output for rendering with line numbers */ +export interface DiffLineResult extends DiffResult { + /** Line-based representation of the diff, derived from segments */ + lines: DiffLine[]; +} diff --git a/src/utils/segmentsToLines.test.ts b/src/utils/segmentsToLines.test.ts new file mode 100644 index 0000000..401b62d --- /dev/null +++ b/src/utils/segmentsToLines.test.ts @@ -0,0 +1,208 @@ +import type { DiffSegment } from 'src/types/diff'; + +import { segmentsToLines } from './segmentsToLines'; + +describe('segmentsToLines', () => { + it('returns empty array for empty segments', () => { + expect(segmentsToLines([])).toEqual([]); + }); + + it('handles a single unchanged line', () => { + const segments: DiffSegment[] = [{ value: 'hello', type: 'unchanged' }]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'hello', + type: 'unchanged', + originalLineNumber: 1, + modifiedLineNumber: 1, + }, + ]); + }); + + it('splits unchanged multi-line segment by newlines', () => { + const segments: DiffSegment[] = [ + { value: 'line1\nline2\nline3\n', type: 'unchanged' }, + ]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'line1', + type: 'unchanged', + originalLineNumber: 1, + modifiedLineNumber: 1, + }, + { + text: 'line2', + type: 'unchanged', + originalLineNumber: 2, + modifiedLineNumber: 2, + }, + { + text: 'line3', + type: 'unchanged', + originalLineNumber: 3, + modifiedLineNumber: 3, + }, + ]); + }); + + it('assigns only original line number for removed lines', () => { + const segments: DiffSegment[] = [{ value: 'removed\n', type: 'removed' }]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'removed', + type: 'removed', + originalLineNumber: 1, + modifiedLineNumber: undefined, + }, + ]); + }); + + it('assigns only modified line number for added lines', () => { + const segments: DiffSegment[] = [{ value: 'added\n', type: 'added' }]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'added', + type: 'added', + originalLineNumber: undefined, + modifiedLineNumber: 1, + }, + ]); + }); + + it('tracks line numbers correctly for mixed segments', () => { + const segments: DiffSegment[] = [ + { value: 'same\n', type: 'unchanged' }, + { value: 'old\n', type: 'removed' }, + { value: 'new\n', type: 'added' }, + { value: 'end\n', type: 'unchanged' }, + ]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'same', + type: 'unchanged', + originalLineNumber: 1, + modifiedLineNumber: 1, + }, + { + text: 'old', + type: 'removed', + originalLineNumber: 2, + modifiedLineNumber: undefined, + }, + { + text: 'new', + type: 'added', + originalLineNumber: undefined, + modifiedLineNumber: 2, + }, + { + text: 'end', + type: 'unchanged', + originalLineNumber: 3, + modifiedLineNumber: 3, + }, + ]); + }); + + it('handles multiple removed lines followed by multiple added lines', () => { + const segments: DiffSegment[] = [ + { value: 'a\nb\n', type: 'removed' }, + { value: 'x\ny\nz\n', type: 'added' }, + ]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'a', + type: 'removed', + originalLineNumber: 1, + modifiedLineNumber: undefined, + }, + { + text: 'b', + type: 'removed', + originalLineNumber: 2, + modifiedLineNumber: undefined, + }, + { + text: 'x', + type: 'added', + originalLineNumber: undefined, + modifiedLineNumber: 1, + }, + { + text: 'y', + type: 'added', + originalLineNumber: undefined, + modifiedLineNumber: 2, + }, + { + text: 'z', + type: 'added', + originalLineNumber: undefined, + modifiedLineNumber: 3, + }, + ]); + }); + + it('handles segment without trailing newline (single-line input)', () => { + const segments: DiffSegment[] = [ + { value: 'hello', type: 'unchanged' }, + { value: 'world', type: 'removed' }, + { value: 'there', type: 'added' }, + ]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: 'hello', + type: 'unchanged', + originalLineNumber: 1, + modifiedLineNumber: 1, + }, + { + text: 'world', + type: 'removed', + originalLineNumber: 1, + modifiedLineNumber: undefined, + }, + { + text: 'there', + type: 'added', + originalLineNumber: undefined, + modifiedLineNumber: 1, + }, + ]); + }); + + it('handles empty string segment', () => { + const segments: DiffSegment[] = [{ value: '', type: 'unchanged' }]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([]); + }); + + it('handles segment that is just a newline', () => { + const segments: DiffSegment[] = [{ value: '\n', type: 'unchanged' }]; + const lines = segmentsToLines(segments); + + expect(lines).toEqual([ + { + text: '', + type: 'unchanged', + originalLineNumber: 1, + modifiedLineNumber: 1, + }, + ]); + }); +}); diff --git a/src/utils/segmentsToLines.ts b/src/utils/segmentsToLines.ts new file mode 100644 index 0000000..f7c51bc --- /dev/null +++ b/src/utils/segmentsToLines.ts @@ -0,0 +1,44 @@ +import type { DiffLine, DiffSegment } from 'src/types/diff'; + +/** + * Transforms flat DiffSegment[] into line-based DiffLine[] with line number metadata. + * Splits each segment by newlines and tracks original/modified line counters. + */ +export function segmentsToLines(segments: DiffSegment[]): DiffLine[] { + const lines: DiffLine[] = []; + let originalLine = 1; + let modifiedLine = 1; + + for (const segment of segments) { + const parts = segment.value.split('\n'); + + for (let i = 0; i < parts.length; i++) { + const isLastPart = i === parts.length - 1; + + if (isLastPart && parts[i] === '') { + break; + } + + const line: DiffLine = { + text: parts[i], + type: segment.type, + originalLineNumber: segment.type === 'added' ? undefined : originalLine, + modifiedLineNumber: + segment.type === 'removed' ? undefined : modifiedLine, + }; + + lines.push(line); + + if (!isLastPart) { + if (segment.type !== 'added') { + originalLine++; + } + if (segment.type !== 'removed') { + modifiedLine++; + } + } + } + } + + return lines; +}