diff --git a/DIFF_BRANCH_CONTINUATION.md b/DIFF_BRANCH_CONTINUATION.md deleted file mode 100644 index dcacdfc32..000000000 --- a/DIFF_BRANCH_CONTINUATION.md +++ /dev/null @@ -1,114 +0,0 @@ -# Diff Branch Continuation Plan - -**Status**: Working! Review Diff UX is functional -**Last Updated**: 2025-12-28 - -## Phase 0: Compilation Fixes (DONE) - -All 14 compilation errors have been fixed: -- Made `TsHighlightSpan` public with `pub` keyword and public fields -- Added `#[derive(Debug, Clone)]` traits -- Added `#[op2(fast)]` attribute to `op_fresh_find_buffer_by_path` -- Fixed Color RGB extraction using pattern matching on `Color::Rgb` -- Added `review_hunks: Vec::new()` initialization -- Added match arms for `SetReviewDiffHunks` and `HighlightsComputed` - ---- - -## Phase 1: Fix UX Bug (DONE) - -### Problem (Fixed) - -When running "Review Diff", the status showed "Generating Review Diff Stream..." -but the *Review Diff* buffer never appeared. - -### Root Cause - -In `renderReviewStream()`, unused code was opening files for syntax highlighting prep: -- `editor.createVirtualBuffer()` for HEAD: versions -- `editor.openFile()` for working copies - -Both stole focus before the Review Diff buffer was created. - -### Fix Applied - -Removed the unused `headBuffers`/`workingBuffers` preparation code and the -`mapHighlights` function. The Review Diff buffer now displays correctly with -colorized diff output (pink for removed lines, blue for context headers) - ---- - -## Phase 2: Complete the Review Diff Feature - -### 2.1 Unified Review Stream (audit_mode.ts) -**Status**: Mostly complete -- [x] Git diff parsing -- [x] Hunk display with box borders -- [x] Stage/Discard actions (s/d keys) -- [x] Hunk navigation (n/p keys) -- [x] Refresh on buffer activation -- [ ] Apply staged hunks to git index -- [ ] Persist staging decisions - -### 2.2 Side-by-Side Drill-Down -**Status**: Scaffolded -- [x] Opens HEAD version in virtual buffer -- [x] Sets up synchronized scrolling -- [ ] Sync scroll doesn't work reliably -- [ ] Missing: Back navigation to unified view - -### 2.3 Syntax Highlighting for Virtual Buffers -**Status**: WIP in Rust -- [x] `RequestHighlights` command added -- [x] `HighlightsComputed` response added -- [ ] Fix compilation errors (see Phase 1) -- [ ] Wire up in plugin to use real highlighting - -### 2.4 Composite Buffer Architecture -**Status**: Proposed in docs, not implemented -- [ ] `SectionDescriptor` struct -- [ ] Multi-source token synthesis -- [ ] Coordinate mapping for editable hunks -- [ ] Input routing to source buffers - ---- - -## Phase 3: Advanced Features (Future) - -### 3.1 Conflict Resolution (3-Pane Merge) -- Visual layout: LOCAL | RESULT | REMOTE -- l/r keys to pick changes -- Editable center pane - -### 3.2 Hunk Editing -- Allow editing within the unified view -- Changes sync back to working copy - -### 3.3 Integration with AI Workflows -- Accept/reject changes from Claude Code -- Batch operations - ---- - -## Files Modified in This Branch - -| File | Purpose | -|------|---------| -| `src/services/plugins/api.rs` | `ReviewHunk`, `SetReviewDiffHunks`, `RequestHighlights`, `HighlightsComputed` | -| `src/services/plugins/runtime.rs` | `TsHighlightSpan`, `op_fresh_get_highlights` | -| `src/app/mod.rs` | `review_hunks` field | -| `src/app/plugin_commands.rs` | `handle_request_highlights` | -| `plugins/audit_mode.ts` | Main Review Diff plugin | -| `docs/AUDIT_AND_VERIFICATION_DESIGN.md` | Feature design | -| `docs/COMPOSITE_BUFFER_ARCHITECTURE.md` | Architecture spec | - ---- - -## Recommended Next Steps - -1. ~~**Fix all 14 compilation errors**~~ ✅ DONE -2. ~~**Fix the UX bug**~~ ✅ DONE - Removed unused buffer prep code -3. ~~**Test the Review Diff UX**~~ ✅ Working - Review Diff buffer displays with colors -4. **Fix synchronized scrolling** in drill-down view -5. **Add "Apply to Index" functionality** to actually stage hunks via git -6. **Evaluate whether Composite Buffer Architecture is needed** for current goals diff --git a/docs/COMPOSITE_BUFFER_ARCHITECTURE.md b/docs/COMPOSITE_BUFFER_ARCHITECTURE.md deleted file mode 100644 index 0a9319085..000000000 --- a/docs/COMPOSITE_BUFFER_ARCHITECTURE.md +++ /dev/null @@ -1,103 +0,0 @@ -# Composite Buffer Architecture - -**Status**: Proposed -**Date**: 2025-12-22 -**Context**: Architectural design for high-performance, multi-source views within Fresh Editor. This architecture powers the "Review Diff" tool and the "Notebook" feature. - ---- - -## 1. Executive Summary - -Traditional editor buffers are single-source: they map 1:1 to a file or a static string. The **Composite Buffer Architecture** introduces a "Virtual Lens" concept, where a single buffer surface is synthesized from multiple **Source Buffers**. - -This architecture enables: -1. **Review Diff**: A vertical stream of hunks from different files, with live syntax highlighting and direct editing. -2. **Notebooks**: A sequence of cells (views into separate buffers) with professional box-drawing borders and execution status. -3. **Cross-Buffer Consistency**: Edits made in a composite view are instantly reflected in the source files, and vice-versa. - ---- - -## 2. Core Concepts - -### 2.1 The Source Buffer -Any standard buffer (file-backed or virtual) that holds raw text and runs a syntax highlighting engine. - -### 2.2 The Composite Buffer -A "Virtual Lens" buffer that contains no primary text of its own. Its content is defined by a **Composite Layout Script**—a sequence of sections provided by a plugin. - -### 2.3 The Section Descriptor -A directive telling the Rust core what to pull and how to frame it: -```rust -struct SectionDescriptor { - id: String, // Unique ID for the section - source_buffer_id: BufferId, - range: Range, // Byte or line range in the source - style: SectionStyle, // Border type, markers (+/-), padding - heading: Option, // Header text (e.g., filename or "In [5]:") - is_editable: bool, // Whether to allow input routing - metadata: serde_json::Value, -} -``` - ---- - -## 3. The Synthesis Pipeline - -The rendering pipeline (`view_pipeline.rs`) is extended to support multi-source token synthesis. - -### 3.1 Token Fetching -Instead of reading raw bytes and tokenizing them, the Composite Buffer renderer: -1. Iterates through the layout's `SectionDescriptors`. -2. For each section, it requests the **already computed tokens** (from the Source Buffer's HighlightEngine). -3. It translates the source offsets into composite offsets. - -### 3.2 Framing (The "Box Engine") -The synthesis engine injects UI-only tokens to create the visual "Frame": -* **Borders**: Box-drawing characters (`┌`, `│`, `└`) are added as tokens with `source_offset: None`. -* **Markers**: Diff indicators (`+`, `-`) or Notebook markers (`In [ ]`) are injected into a dedicated gutter column. -* **Styling**: Framing tokens use specific UI theme colors, while content tokens preserve their original syntax colors. - ---- - -## 4. Live Editing & Input Routing - -This is the most advanced part of the architecture. It allows the user to treat a composite box as a real editor. - -### 4.1 Coordinate Mapping -The `ViewLine` structure is updated to provide a bidirectional mapping between the **Composite Viewport** and the **Source Buffer**. -* **Display Column 10** → maps to **Source Buffer 5, Byte Offset 120**. -* **Display Column 2** (Border) → maps to **None** (Protected). - -### 4.2 Event Redirection -When a key is pressed in a Composite split: -1. The editor identifies the buffer/byte under the cursor via the Mapping Table. -2. If the mapping exists and `is_editable` is true: - * The `Insert` or `Delete` event is **rerouted** to the Source Buffer. - * The Source Buffer applies the change and notifies its listeners. -3. If the cursor is on a protected character (border/header), the input is blocked ("Editing disabled"). - -### 4.3 Unified Undo/Redo -Edits made via the composite view are recorded in the **Source Buffer's Event Log**. This ensures that undoing a change in the primary file correctly updates the composite view as well. - ---- - -## 5. Feature Implementation Details - -### 5.1 Review Diff (Professional TUI) -* **Layout**: A sequence of sections pairing `Git HEAD` (readonly) and `Working Copy` (editable). -* **Visuals**: Single-line borders connecting hunks of the same file. `+` / `-` markers in a narrow gutter. -* **Refresh**: The plugin updates the layout script when `git diff` changes. - -### 5.2 Jupyter Notebooks -* **Layout**: Code cells (editable) followed by Output cells (readonly). -* **Visuals**: Rounded borders, distinct spacing between cells. `In [x]:` markers. -* **LSP Support**: Since input is routed to real source buffers, the language's LSP provides full completion and diagnostics within the notebook box. - ---- - -## 6. Implementation Roadmap - -1. **Phase 1: Multi-Source Pipeline**: Update `ViewTokenWire` and `ViewLineIterator` to track `BufferId` per character. -2. **Phase 2: Layout Script API**: Add `editor.setCompositeLayout` to allow plugins to register section descriptors. -3. **Phase 3: The Box Engine**: Implement Rust-side synthesis of borders and gutters. -4. **Phase 4: Input Routing**: Update `Editor::handle_key` to support rerouting based on character mappings. diff --git a/docs/REVIEW_DIFF_FEATURE.md b/docs/REVIEW_DIFF_FEATURE.md deleted file mode 100644 index b54cbc409..000000000 --- a/docs/REVIEW_DIFF_FEATURE.md +++ /dev/null @@ -1,323 +0,0 @@ -# Review Diff Feature - -**Status**: Partially Implemented -**Last Updated**: 2025-12-28 - -A TUI-based code review workflow for reviewing changes generated by AI agents or collaborators. - ---- - -## Overview - -The Review Diff feature transforms Fresh Editor into a "decision engine" for reviewing, annotating, and staging code changes. It's specifically designed for the human-in-the-loop workflow when working with AI coding agents. - -### Core Capabilities - -| Feature | Status | Description | -|---------|--------|-------------| -| Unified Diff View | ✅ Done | Single scrollable stream of all changes | -| Colorized Diff | ✅ Done | Red=removed, Green=added, Blue=context | -| Hunk Navigation | ✅ Done | `n`/`p` keys jump between hunks | -| Per-Hunk Staging | ✅ Done | `s`/`d` to stage/discard hunks | -| Side-by-Side Drill-down | ✅ Done | `Enter` opens synchronized split view | -| Line-Level Comments | ✅ Done | `c` to comment on specific lines | -| Hunk Status Marking | ✅ Done | `a`pprove, `x`reject, `!`needs-changes | -| Export to Markdown | ✅ Done | `E` exports to `.review/session.md` | -| Export to JSON | ✅ Done | Structured format for agent consumption | -| Help Header | ✅ Done | Keybindings visible at top of buffer | -| Original Prompt Display | ❌ TODO | Show what user asked the agent to do | -| Summary Header | ❌ TODO | At-a-glance change summary | -| Safety Guardrails | ❌ TODO | Warn about secrets, deletions, etc. | -| Conflict Resolution | 🔧 Scaffolded | 3-pane merge view | - ---- - -## Quick Start - -1. Open Review Diff: `Ctrl+P` → "Review Diff" -2. Navigate: Arrow keys to move, `n`/`p` to jump between hunks -3. Comment: `c` on any line to add feedback -4. Review: `a` approve, `x` reject, `!` needs changes -5. Export: `E` to save feedback to `.review/session.md` - ---- - -## Keyboard Shortcuts (review-mode) - -### Navigation -| Key | Action | -|-----|--------| -| `n` | Next hunk | -| `p` | Previous hunk | -| `Enter` | Drill down to side-by-side view | -| `r` | Refresh diff | -| `q` | Close buffer | -| Arrow keys | Move cursor within buffer | - -### Commenting -| Key | Action | -|-----|--------| -| `c` | Add comment at cursor position | -| `O` | Set overall session feedback | - -### Review Status -| Key | Action | -|-----|--------| -| `a` | Approve hunk | -| `x` | Reject hunk | -| `!` | Mark as needs changes | -| `?` | Mark with question | -| `u` | Clear/undo status | - -### Staging -| Key | Action | -|-----|--------| -| `s` | Stage hunk (accept change) | -| `d` | Discard hunk (reject change) | - -### Export -| Key | Action | -|-----|--------| -| `E` | Export to `.review/session.md` | - ---- - -## Comment System - -Comments are attached to specific file line numbers (not hunk-relative), making them robust to rebases and squashes. - -### Adding Comments - -1. Navigate to a specific line in the diff -2. Press `c` -3. Enter your comment text -4. Press Enter to confirm - -The prompt shows the line reference: -- `Comment on +42:` for added lines (new file line 42) -- `Comment on -38:` for removed lines (old file line 38) -- `Comment on hunk:` when on hunk header - -### Comment Display - -Comments appear inline after the line they reference: - -``` -│ - const token = req.headers.auth; -│ + const token = req.headers.authorization; -│ » [+45] Consider adding validation here -│ const decoded = jwt.verify(token); -``` - -### Export Format - -Comments are exported with full context: - -**Markdown (`.review/session.md`)**: -```markdown -## File: src/auth.ts - -### validateToken (line 45) -**Status**: NEEDS_CHANGES - -**Comments:** -> » [+45] Consider adding validation here -> `const token = req.headers.authorization;` -``` - -**JSON (`.review/session.json`)**: -```json -{ - "files": { - "src/auth.ts": { - "hunks": [{ - "context": "validateToken", - "new_lines": [45, 52], - "old_lines": [45, 50], - "status": "needs_changes", - "comments": [{ - "text": "Consider adding validation here", - "line_type": "add", - "new_line": 45, - "line_content": "+const token = req.headers.authorization;" - }] - }] - } - } -} -``` - ---- - -## Agent Integration - -The export formats are designed for any AI coding agent to consume: - -``` -Before making changes, check for review feedback: -1. Read `.review/session.md` if it exists -2. Address each NEEDS_CHANGES item -3. Do not re-apply REJECTED changes -4. Keep APPROVED changes as-is -``` - -Compatible with: Claude Code, Cursor, Aider, GitHub Copilot, and any agent that can read files. - ---- - -## Architecture - -### Components - -``` -plugins/audit_mode.ts # Main plugin (TypeScript) - ├── getGitDiff() # Parses `git diff HEAD` - ├── renderReviewStream()# Generates virtual buffer content - ├── review_* actions # Keybinding handlers - └── export functions # Markdown/JSON generation - -src/services/plugins/ # Rust runtime - ├── Virtual Buffers # Read-only buffers with metadata - ├── Text Properties # Per-line metadata (hunkId, lineType, etc.) - └── Overlays # Diff highlighting -``` - -### Data Structures - -```typescript -interface ReviewComment { - id: string; - hunk_id: string; - file: string; - text: string; - timestamp: string; - old_line?: number; // Line in old file version - new_line?: number; // Line in new file version - line_content?: string; // Actual line text for context - line_type?: 'add' | 'remove' | 'context'; -} - -interface Hunk { - id: string; - file: string; - range: { start: number; end: number }; // New file lines - oldRange: { start: number; end: number }; // Old file lines - lines: string[]; - status: 'pending' | 'staged' | 'discarded'; - reviewStatus: 'pending' | 'approved' | 'rejected' | 'needs_changes' | 'question'; - contextHeader: string; // Function/class name from @@ line - byteOffset: number; // Position in virtual buffer -} -``` - ---- - -## Side-by-Side View - -The side-by-side drill-down view (`Enter` on a hunk) shows both file versions simultaneously with synchronized scrolling. - -### Current Implementation - -- **Left pane**: NEW version (current file) - editable working copy -- **Right pane**: OLD version (`[OLD ◀] filename`) - read-only virtual buffer with editing disabled -- **Scroll sync**: Viewport changes trigger `setSplitScroll` for synchronized scrolling -- **Activation**: Press `Enter` on any line within a hunk - -**Note**: The layout is NEW|OLD rather than the conventional OLD|NEW due to the split API creating new panes to the right. The `[OLD ◀]` label makes it clear which pane is the reference version. - ---- - -## UX Evaluation (NN/g Heuristics) - -Evaluation of side-by-side diff view against [Nielsen Norman Group's 10 Usability Heuristics](https://www.nngroup.com/articles/ten-usability-heuristics/). - -### Issues Found - -#### ~~Critical: Read-Only Not Enforced~~ ✅ FIXED -**Heuristic**: #5 Error Prevention -- **Issue**: The OLD file buffer accepted text input despite `read_only: true` setting -- **Fix**: Added `editing_disabled: true` to the virtual buffer creation options - -#### High: Reversed Pane Order (Known Limitation) -**Heuristic**: #2 Match Between System and Real World -- **Issue**: NEW file is on LEFT, OLD file is on RIGHT -- **Expected**: OLD on LEFT → NEW on RIGHT (reading direction: past → present) -- **Convention**: GitHub, GitLab, VS Code, and all major diff tools use OLD|NEW order -- **Impact**: Violates the mental model that left=before, right=after -- **Status**: API limitation - `createVirtualBufferInSplit` creates splits to the right -- **Mitigation**: Clear `[OLD ◀]` label distinguishes the reference version - -#### High: No Line-Level Alignment -**Heuristic**: #6 Recognition Rather Than Recall -- **Issue**: Lines are not aligned semantically between panes -- **Example**: Line 8 on left shows `const data = parseInput()` while line 8 on right shows `}` -- **Impact**: Users must mentally map corresponding changes -- **Fix**: Implement line-pairing algorithm or show unified diff markers - -#### ~~Medium: Ambiguous Pane Labels~~ ✅ FIXED -**Heuristic**: #1 Visibility of System Status -- **Issue**: Both tabs showed similar names (`test_review.ts` vs `HEAD:test_review.ts`) -- **Fix**: OLD pane now labeled `[OLD ◀] filename` with arrow indicator - -#### Medium: No Help in Split View -**Heuristic**: #10 Help and Documentation -- **Issue**: Help keybindings visible in review buffer disappear in split view -- **Impact**: Users don't know available actions in split view mode -- **Fix**: Add floating help or status bar hints - -#### Low: Byte-Based vs Line-Based Sync -**Heuristic**: #4 Consistency and Standards -- **Issue**: Scroll sync uses `top_byte` offset, not line numbers -- **Impact**: Files with different line lengths may scroll at different rates -- **Recommendation**: Consider line-based synchronization for semantic alignment - -### What Works Well - -| Aspect | Assessment | -|--------|------------| -| Vertical split layout | Good use of screen real estate | -| Synchronized scrolling | Works for basic navigation | -| Color-coded diff lines | Clear visual distinction (when colors render) | -| 0.5 ratio split | Equal space for comparison | - -### Recommended Fixes (Priority Order) - -1. ~~**Enforce read-only on OLD pane**~~ ✅ DONE - Added `editing_disabled: true` -2. **Swap pane order to OLD|NEW** - Requires split API enhancement (left-side split) -3. ~~**Add clear pane labels**~~ ✅ DONE - `[OLD ◀]` prefix on old file tab -4. **Implement line alignment** - Significant effort, high value - ---- - -## Known Issues / TODO - -### High Priority -1. **Arrow key navigation only**: `j`/`k` don't work in review-mode (use arrow keys) -2. **No prompt display**: Original request from agent not shown -3. **No summary header**: No at-a-glance change statistics -4. **Side-by-side pane order reversed**: NEW|OLD instead of OLD|NEW (API limitation, mitigated with labels) - -### Medium Priority -5. **No safety warnings**: Secrets, deletions not highlighted -6. **No persistence**: Comments lost on editor restart -7. **No edit/delete comments**: Can only add, not modify -8. **No line alignment in split view**: Lines don't correspond semantically -9. **No help in split view**: Keybindings not visible in side-by-side mode - -### Low Priority -10. **No confidence indicators**: All changes look equally confident -11. **No semantic grouping**: Changes shown by file, not by logical function -12. **No LSP integration**: Can't show impact analysis - ---- - -## Development History - -- **2025-12-22**: Initial Review Diff implementation -- **2025-12-28**: Added review comments with file line numbers -- **2025-12-28**: Fixed inline comment rendering for paired diffs -- **2025-12-28**: Added help header with keybindings -- **2025-12-28**: Export includes line-specific comment info -- **2025-12-28**: UX evaluation of side-by-side view (NN/g heuristics) -- **2025-12-29**: Fixed read-only enforcement on OLD pane (`editing_disabled: true`) -- **2025-12-29**: Added clear `[OLD ◀]` label to distinguish reference version diff --git a/docs/internal/DIFF_VIEW.md b/docs/internal/DIFF_VIEW.md new file mode 100644 index 000000000..9817ee6fd --- /dev/null +++ b/docs/internal/DIFF_VIEW.md @@ -0,0 +1,542 @@ +# Diff View Design + +**Status**: In Development +**Last Updated**: 2025-01-01 + +This document consolidates all design documentation for the diff viewing and code review features in Fresh Editor. + +--- + +# Part 1: UX Design + +## Overview + +The diff view feature transforms Fresh Editor into a "decision engine" for reviewing, annotating, and staging code changes. It supports two primary workflows: + +1. **Review Diff** - Unified stream view for reviewing AI-generated or collaborator changes +2. **Side-by-Side Diff** - Traditional two-pane comparison view + +## Quick Start + +1. Open Review Diff: `Ctrl+P` → "Review Diff" +2. Navigate: Arrow keys to move, `n`/`p` to jump between hunks +3. Comment: `c` on any line to add feedback +4. Review: `a` approve, `x` reject, `!` needs changes +5. Drill down: `Enter` on a hunk for side-by-side view +6. Export: `E` to save feedback to `.review/session.md` + +## Keyboard Shortcuts (review-mode) + +### Navigation +| Key | Action | +|-----|--------| +| `n` | Next hunk | +| `p` | Previous hunk | +| `Enter` | Drill down to side-by-side view | +| `r` | Refresh diff | +| `q` | Close buffer | +| Arrow keys | Move cursor within buffer | + +### Commenting +| Key | Action | +|-----|--------| +| `c` | Add comment at cursor position | +| `O` | Set overall session feedback | + +### Review Status +| Key | Action | +|-----|--------| +| `a` | Approve hunk | +| `x` | Reject hunk | +| `!` | Mark as needs changes | +| `?` | Mark with question | +| `u` | Clear/undo status | + +### Staging +| Key | Action | +|-----|--------| +| `s` | Stage hunk (accept change) | +| `d` | Discard hunk (reject change) | + +### Export +| Key | Action | +|-----|--------| +| `E` | Export to `.review/session.md` | + +## Visual Layout + +### Unified Review Stream +``` +┌─────────────────────────────────────────────────────┐ +│ [Keybindings: n=next p=prev s=stage d=discard ...] │ +├─────────────────────────────────────────────────────┤ +│ src/auth.ts │ +│ @@ -45,7 +45,9 @@ function validateToken() │ +│ const token = req.headers.auth; │ ← context (unchanged) +│ - const decoded = jwt.verify(token); │ ← deletion (red bg) +│ + const decoded = jwt.verify(token, secret); │ ← addition (green bg) +│ + if (!decoded) throw new AuthError(); │ ← addition (green bg) +│ return decoded.userId; │ ← context +│ » [+47] Consider adding validation here │ ← inline comment +└─────────────────────────────────────────────────────┘ +``` + +### Side-by-Side View +``` +┌──────────────────────────┬──────────────────────────┐ +│ OLD │ NEW │ +├──────────────────────────┼──────────────────────────┤ +│ 45 │ const token = ... │ 45 │ const token = ... │ +│ 46 │ jwt.verify(token); │ 46 │ jwt.verify(token, │ +│ │ │ 47 │ if (!decoded) ... │ ← gap alignment +│ 47 │ return decoded... │ 48 │ return decoded... │ +└──────────────────────────┴──────────────────────────┘ +``` + +## Comment System + +Comments are attached to specific file line numbers (not hunk-relative), making them robust to rebases and squashes. + +### Adding Comments +1. Navigate to a specific line in the diff +2. Press `c` +3. Enter your comment text +4. Press Enter to confirm + +The prompt shows the line reference: +- `Comment on +42:` for added lines (new file line 42) +- `Comment on -38:` for removed lines (old file line 38) +- `Comment on hunk:` when on hunk header + +### Export Format + +**Markdown (`.review/session.md`)**: +```markdown +## File: src/auth.ts + +### validateToken (line 45) +**Status**: NEEDS_CHANGES + +**Comments:** +> » [+45] Consider adding validation here +> `const token = req.headers.authorization;` +``` + +**JSON (`.review/session.json`)**: +```json +{ + "files": { + "src/auth.ts": { + "hunks": [{ + "context": "validateToken", + "new_lines": [45, 52], + "status": "needs_changes", + "comments": [{ + "text": "Consider adding validation here", + "line_type": "add", + "new_line": 45 + }] + }] + } + } +} +``` + +## UX Requirements + +### Must Have +- [x] Colorized diff output (red=removed, green=added, blue=context) +- [x] Hunk navigation (n/p keys) +- [x] Per-hunk staging (s/d keys) +- [x] Side-by-side drill-down +- [x] Line-level comments +- [x] Export to markdown/JSON +- [ ] Line-level alignment in side-by-side view (gaps for insertions) +- [ ] Syntax highlighting in diff view + +### Nice to Have +- [ ] Original prompt display (what user asked the agent) +- [ ] Summary header (at-a-glance change statistics) +- [ ] Safety warnings (secrets, large deletions) +- [ ] 3-pane merge view for conflicts + +## Known UX Issues + +1. **No line alignment**: Side-by-side view doesn't align corresponding lines with gaps +2. **No syntax highlighting**: Diff content shows plain text colors +3. **Arrow key navigation only**: `j`/`k` don't work in review-mode +4. **Pane order reversed**: NEW|OLD instead of conventional OLD|NEW (API limitation) + +--- + +# Part 2: Architecture & Implementation + +## Design Decision: Dual-Mode Rendering (Option E) + +After analyzing the rendering pipeline, we chose **dual-mode rendering** for aligned diff views: + +### The Problem + +The normal buffer rendering pipeline (`render_view_lines`) renders **consecutive lines** from a buffer. Aligned diff views require **non-consecutive rendering with gaps**: + +``` +Old | New +Line 1 | Line 1 +Line 2 | Line 2 +Line 3 | Line 3 +[gap] | Line 4 ← insertion, old side shows gap +Line 4 | Line 5 +``` + +Calling `render_buffer_in_split()` with a calculated `top_byte` would render consecutive lines without gaps. + +### Solution: Dual-Mode Rendering + +1. **Normal mode**: Existing `render_view_lines()` for regular buffers +2. **Aligned mode**: New `render_aligned_view_lines()` for composite buffers + +Both modes share: +- `build_view_data()` - token building, syntax highlighting, line wrapping +- Extracted helper functions for gutter, character styling, cursor rendering + +The aligned mode: +- Takes ViewData for each pane + alignment info +- For each display row, looks up the ViewLine for each pane (or renders a gap) +- Renders with shared helper functions + +### Why Not Full Reuse? + +We considered having CompositeBuffer call `render_buffer_in_split()` per pane, but: +- That renders consecutive lines, not aligned lines with gaps +- The alignment requires rendering specific source lines at specific display rows +- Gap rows have no source content to render + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CompositeBuffer │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Pane 0 │ │ Pane 1 │ │ +│ │ ┌────────────┐ │ │ ┌────────────┐ │ │ +│ │ │EditorState │ │ │ │EditorState │ │ │ +│ │ │ - buffer │ │ │ │ - buffer │ │ │ +│ │ │ - cursors │ │ │ │ - cursors │ │ │ +│ │ │ - highlight│ │ │ │ - highlight│ │ │ +│ │ │ - overlays │ │ │ │ - overlays │ │ │ +│ │ └────────────┘ │ │ └────────────┘ │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ ChunkAlignment │ │ +│ │ chunks: [Context, Hunk, Context, Hunk, Context, ...] │ │ +│ │ (markers at chunk boundaries for edit-robustness) │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ scroll_display_row: usize (unified scroll position) │ +│ focused_pane: usize (which pane receives input) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Core Data Structures + +### CompositeBuffer + +```rust +pub struct CompositeBuffer { + pub id: BufferId, + pub name: String, + pub layout: CompositeLayout, + pub sources: Vec, + pub alignment: LineAlignment, + pub active_pane: usize, + pub mode: String, +} + +pub enum CompositeLayout { + SideBySide { ratios: Vec, show_separator: bool }, + Stacked { spacing: u16 }, + Unified, +} + +pub struct SourcePane { + pub buffer_id: BufferId, + pub label: String, + pub editable: bool, + pub style: PaneStyle, + pub range: Option>, +} +``` + +### ChunkAlignment (Edit-Robust) + +Traditional alignment stores line numbers, which break on edit. We use **markers at chunk boundaries**: + +```rust +struct ChunkAlignment { + chunks: Vec, +} + +struct AlignmentChunk { + /// Marker at the START of this chunk in each pane + /// None if this pane has no content (e.g., pure insertion) + start_markers: Vec>, + kind: ChunkKind, + dirty: bool, // Needs recomputation after edit +} + +enum ChunkKind { + Context { line_count: usize }, + Hunk { ops: Vec<(usize, usize)> }, // (old_lines, new_lines) pairs +} +``` + +**Example:** +``` + Line 1 | Line 1 (context) + Line 2 | Line 2 (context) +- Line 3 | (deletion) +- Line 4 | (deletion) + |+ New 3 (insertion) + Line 5 | Line 5 (context) +``` + +Becomes: +```rust +chunks: [ + AlignmentChunk { + start_markers: [M0_old, M0_new], // At "Line 1" + kind: Context { line_count: 2 }, + }, + AlignmentChunk { + start_markers: [M1_old, M1_new], // At "Line 3" / "New 3" + kind: Hunk { ops: [(1,0), (1,0), (0,1)] }, // del, del, ins + }, + AlignmentChunk { + start_markers: [M2_old, M2_new], // At "Line 5" + kind: Context { line_count: 1 }, + }, +] +``` + +**Total: 6 markers** (2 per chunk) instead of one per line. + +### Edit Handling + +When a buffer is edited: +1. **Markers auto-adjust** their byte positions (handled by buffer's marker system) +2. **Context chunks**: Update `line_count` based on lines inserted/deleted +3. **Hunk chunks**: Mark as `dirty` for localized re-diffing + +```rust +impl ChunkAlignment { + fn on_buffer_edit(&mut self, pane_idx: usize, edit_line: usize, lines_delta: isize) { + for chunk in &mut self.chunks { + if chunk.contains_line(pane_idx, edit_line) { + match &mut chunk.kind { + ChunkKind::Context { line_count } => { + *line_count = (*line_count as isize + lines_delta) as usize; + } + ChunkKind::Hunk { .. } => { + chunk.dirty = true; + } + } + return; + } + } + } +} +``` + +## Rendering Pipeline + +### Normal Buffer (Existing) +``` +EditorState + ↓ build_view_data() +ViewData { lines: Vec } + ↓ render_view_lines() +Screen +``` + +### Composite Buffer (New) +``` +Per-pane EditorState + ↓ build_view_data() (reused!) +Per-pane ViewData + ↓ render_aligned_view_lines() (new!) +Screen with aligned panes +``` + +### render_aligned_view_lines + +```rust +fn render_aligned_view_lines( + frame: &mut Frame, + pane_areas: &[Rect], + pane_view_data: &[ViewData], + alignment: &ChunkAlignment, + view_state: &CompositeViewState, + theme: &Theme, +) { + let display_rows = alignment.to_display_rows(); + + for (view_row, aligned_row) in display_rows.iter() + .skip(view_state.scroll_row) + .take(viewport_height) + .enumerate() + { + for (pane_idx, pane_area) in pane_areas.iter().enumerate() { + let row_rect = Rect { y: pane_area.y + view_row, height: 1, ..*pane_area }; + + match aligned_row.get_pane_line(pane_idx) { + Some(source_line) => { + // Find ViewLine for this source line + let view_line = find_view_line(&pane_view_data[pane_idx], source_line); + render_single_view_line(frame, row_rect, view_line, ...); + } + None => { + // Gap row - render empty with appropriate background + render_gap_row(frame, row_rect, aligned_row.row_type, theme); + } + } + } + } +} +``` + +## Scroll Synchronization + +The composite buffer has a unified `scroll_display_row`. Each pane's viewport is derived: + +```rust +impl CompositeBuffer { + fn derive_pane_top_byte(&self, pane_idx: usize, display_row: usize) -> usize { + let display_rows = self.alignment.to_display_rows(); + + display_rows + .get(display_row) + .and_then(|row| row.pane_lines.get(pane_idx)) + .flatten() + .and_then(|line| self.pane_buffer(pane_idx).line_start_offset(line)) + .unwrap_or(0) + } +} +``` + +## Input Routing + +Cursor and edit actions go to the focused pane's EditorState: + +```rust +impl CompositeBuffer { + fn handle_action(&mut self, action: Action) -> Option { + match action { + Action::FocusNextPane => { + self.focused_pane = (self.focused_pane + 1) % self.panes.len(); + None + } + Action::CursorDown => { + self.scroll_display_row += 1; + self.focused_pane_state_mut().handle_cursor_down() + } + Action::Insert(_) | Action::Delete => { + self.focused_pane_state_mut().handle_action(action) + } + _ => None + } + } +} +``` + +## Diff Highlighting via Overlays + +Add overlays to each pane's EditorState based on alignment: + +```rust +fn apply_diff_overlays(&mut self, theme: &Theme) { + let display_rows = self.alignment.to_display_rows(); + + for (pane_idx, pane_state) in self.pane_states.iter_mut().enumerate() { + pane_state.overlays.clear_category("diff"); + + for row in &display_rows { + if let Some(source_line) = row.pane_lines.get(pane_idx).flatten() { + let bg_color = match row.row_type { + RowType::Addition => Some(theme.diff_add_bg), + RowType::Deletion => Some(theme.diff_remove_bg), + RowType::Modification => Some(theme.diff_modify_bg), + _ => None, + }; + + if let Some(color) = bg_color { + let range = pane_state.buffer.line_byte_range(source_line); + pane_state.overlays.add(Overlay { + range, + face: OverlayFace::Background { color }, + category: "diff".to_string(), + }); + } + } + } + } +} +``` + +## File Structure + +| File | Purpose | +|------|---------| +| `src/model/composite_buffer.rs` | CompositeBuffer, SourcePane, LineAlignment, ChunkAlignment | +| `src/view/composite_view.rs` | CompositeViewState, PaneViewport | +| `src/view/ui/split_rendering.rs` | render_aligned_view_lines, helper extraction | +| `src/input/composite_router.rs` | Input routing to focused pane | +| `src/app/composite_buffer_actions.rs` | Editor methods for composite buffers | +| `plugins/audit_mode.ts` | Review Diff plugin (TypeScript) | + +## Implementation Phases + +### Phase 1: Helper Extraction (Current) +- [ ] Extract gutter rendering from render_view_lines +- [ ] Extract character style computation +- [ ] Extract cursor rendering logic +- [ ] Create render_single_view_line helper + +### Phase 2: Aligned Rendering +- [ ] Implement render_aligned_view_lines +- [ ] Add source_line → ViewLine lookup +- [ ] Implement gap row rendering +- [ ] Wire up to composite buffer path + +### Phase 3: ChunkAlignment +- [ ] Implement ChunkAlignment with markers +- [ ] Add to_display_rows() conversion +- [ ] Implement on_buffer_edit() for live updates +- [ ] Add dirty chunk re-diffing + +### Phase 4: Polish +- [ ] Syntax highlighting in diff view +- [ ] Cursor navigation within aligned view +- [ ] Selection across aligned rows +- [ ] Performance optimization + +## Summary + +| Aspect | Design | +|--------|--------| +| **Rendering approach** | Dual-mode: consecutive (normal) + aligned (composite) | +| **ViewLine building** | Fully reused via build_view_data() | +| **Alignment storage** | Chunks with markers at boundaries | +| **Edit robustness** | Markers auto-adjust; context updates count; hunks marked dirty | +| **Scroll sync** | Unified display_row → per-pane top_byte via alignment | +| **Diff highlighting** | Overlays on pane EditorStates | +| **Input handling** | Route to focused pane's EditorState | + +## Benefits + +1. **ViewLine reuse** - syntax highlighting, ANSI, virtual text all work +2. **Edit-robust alignment** - markers + chunks handle edits gracefully +3. **Minimal markers** - O(chunks) not O(lines) +4. **Localized recomputation** - only dirty chunks re-diffed +5. **Low risk** - existing render_view_lines unchanged diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 6d39b50b8..e9d34590d 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -369,6 +369,108 @@ interface CreateVirtualBufferInCurrentSplitOptions { | `show_cursors` | Whether to show cursors in the buffer (default true) | | `editing_disabled` | Whether editing is disabled for this buffer (default false) | +### TsCompositeLayoutConfig + +Layout configuration for composite buffers + +```typescript +interface TsCompositeLayoutConfig { + layout_type: string; + ratios?: number[] | null; + show_separator?: boolean | null; + spacing?: u16 | null; +} +``` + +| Field | Description | +|-------|-------------| +| `layout_type` | Layout type: "side-by-side", "stacked", or "unified" | +| `ratios` | Relative widths for side-by-side layout (e.g., [0.5, 0.5]) | +| `show_separator` | Show separator between panes | +| `spacing` | Spacing between stacked panes | + +### TsCompositePaneStyle + +Pane style configuration + +```typescript +interface TsCompositePaneStyle { + add_bg?: [number, number, number] | null; + remove_bg?: [number, number, number] | null; + modify_bg?: [number, number, number] | null; + gutter_style?: string | null; +} +``` + +| Field | Description | +|-------|-------------| +| `add_bg` | Background color for added lines (RGB tuple) | +| `remove_bg` | Background color for removed lines (RGB tuple) | +| `modify_bg` | Background color for modified lines (RGB tuple) | +| `gutter_style` | Gutter style: "line-numbers", "diff-markers", "both", "none" | + +### TsCompositeSourceConfig + +Source pane configuration for composite buffers + +```typescript +interface TsCompositeSourceConfig { + buffer_id: number; + label?: string | null; + editable: boolean; + style?: TsCompositePaneStyle | null; +} +``` + +| Field | Description | +|-------|-------------| +| `buffer_id` | Buffer ID to display in this pane | +| `label` | Label for the pane (shown in header) | +| `editable` | Whether the pane is editable | +| `style` | Pane styling options | + +### TsCompositeHunk + +Diff hunk configuration + +```typescript +interface TsCompositeHunk { + old_start: number; + old_count: number; + new_start: number; + new_count: number; +} +``` + +| Field | Description | +|-------|-------------| +| `old_start` | Start line in old file (0-indexed) | +| `old_count` | Number of lines in old file | +| `new_start` | Start line in new file (0-indexed) | +| `new_count` | Number of lines in new file | + +### CreateCompositeBufferOptions + +Options for creating a composite buffer + +```typescript +interface CreateCompositeBufferOptions { + name: string; + mode: string; + layout: TsCompositeLayoutConfig; + sources: TsCompositeSourceConfig[]; + hunks?: TsCompositeHunk[] | null; +} +``` + +| Field | Description | +|-------|-------------| +| `name` | Display name for the composite buffer (shown in tab) | +| `mode` | Mode for keybindings (e.g., "diff-view") | +| `layout` | Layout configuration | +| `sources` | Source panes to display | +| `hunks` | Optional diff hunks for line alignment | + ### ActionSpecJs JavaScript representation of ActionSpec (with optional count) @@ -1208,6 +1310,52 @@ startPromptWithInitial(label: string, prompt_type: string, initial_value: string | `prompt_type` | `string` | Type identifier (e.g., "git-grep") | | `initial_value` | `string` | Initial text to pre-fill in the prompt | +#### `createCompositeBuffer` + +Create a composite buffer that displays multiple source buffers +Composite buffers allow displaying multiple underlying buffers in a single +tab/view area with custom layouts (side-by-side, stacked, unified). +This is useful for diff views, merge conflict resolution, etc. + +```typescript +createCompositeBuffer(options: CreateCompositeBufferOptions): Promise +``` + +**Parameters:** + +| Name | Type | Description | +|------|------|-------------| +| `options` | `CreateCompositeBufferOptions` | Configuration for the composite buffer | + +#### `updateCompositeAlignment` + +Update line alignment for a composite buffer + +```typescript +updateCompositeAlignment(buffer_id: number, hunks: TsCompositeHunk[]): boolean +``` + +**Parameters:** + +| Name | Type | Description | +|------|------|-------------| +| `buffer_id` | `number` | The composite buffer ID | +| `hunks` | `TsCompositeHunk[]` | New diff hunks for alignment | + +#### `closeCompositeBuffer` + +Close a composite buffer + +```typescript +closeCompositeBuffer(buffer_id: number): boolean +``` + +**Parameters:** + +| Name | Type | Description | +|------|------|-------------| +| `buffer_id` | `number` | The composite buffer ID to close | + #### `sendLspRequest` Send an arbitrary LSP request and receive the raw JSON response diff --git a/plugins/audit_mode.ts b/plugins/audit_mode.ts index 042d7968d..a032972a3 100644 --- a/plugins/audit_mode.ts +++ b/plugins/audit_mode.ts @@ -1009,6 +1009,16 @@ interface SideBySideDiffState { let activeSideBySideState: SideBySideDiffState | null = null; let nextScrollSyncGroupId = 1; +// State for composite buffer-based diff view +interface CompositeDiffState { + compositeBufferId: number; + oldBufferId: number; + newBufferId: number; + filePath: string; +} + +let activeCompositeDiffState: CompositeDiffState | null = null; + globalThis.review_drill_down = async () => { const bid = editor.getActiveBufferId(); const props = editor.getTextPropertiesAtCursor(bid); @@ -1048,21 +1058,9 @@ globalThis.review_drill_down = async () => { return; } - // Close the Review Diff buffer to make room for side-by-side view - // Store the review buffer ID so we can restore it later - const reviewBufferId = bid; - - // Compute aligned diff for the FULL file with all hunks - const alignedLines = computeFullFileAlignedDiff(oldContent, newContent, fileHunks); - - // Generate content for both panes - const oldPane = generateDiffPaneContent(alignedLines, 'old'); - const newPane = generateDiffPaneContent(alignedLines, 'new'); - - // Close any existing side-by-side views + // Close any existing side-by-side views (old split-based approach) if (activeSideBySideState) { try { - // Remove scroll sync group first if (activeSideBySideState.scrollSyncGroupId !== null) { (editor as any).removeScrollSyncGroup(activeSideBySideState.scrollSyncGroupId); } @@ -1072,143 +1070,130 @@ globalThis.review_drill_down = async () => { activeSideBySideState = null; } - // Get the current split ID before closing the Review Diff buffer - const currentSplitId = (editor as any).getActiveSplitId(); + // Close any existing composite diff view + if (activeCompositeDiffState) { + try { + editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId); + editor.closeBuffer(activeCompositeDiffState.oldBufferId); + editor.closeBuffer(activeCompositeDiffState.newBufferId); + } catch {} + activeCompositeDiffState = null; + } + + // Create virtual buffers for old and new content + const oldLines = oldContent.split('\n'); + const newLines = newContent.split('\n'); - // Create OLD buffer in the CURRENT split (replaces Review Diff) - const oldBufferId = await editor.createVirtualBufferInExistingSplit({ - name: `[OLD] ${h.file}`, - mode: "diff-view", + const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({ + text: line + '\n', + properties: { type: 'line', lineNum: idx + 1 } + })); + + const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({ + text: line + '\n', + properties: { type: 'line', lineNum: idx + 1 } + })); + + // Create source buffers (used by composite) + const oldBufferId = await editor.createVirtualBuffer({ + name: `*OLD:${h.file}*`, + mode: "normal", read_only: true, - editing_disabled: true, - entries: oldPane.entries, - split_id: currentSplitId, - show_line_numbers: false, - line_wrap: false + entries: oldEntries, + show_line_numbers: true, + editing_disabled: true }); - const oldSplitId = currentSplitId; - - // Close the Review Diff buffer after showing OLD - editor.closeBuffer(reviewBufferId); - - // Apply highlights to old pane - editor.clearNamespace(oldBufferId, "diff-view"); - for (const hl of oldPane.highlights) { - const bg = hl.bg || [-1, -1, -1]; - editor.addOverlay( - oldBufferId, "diff-view", - hl.range[0], hl.range[1], - hl.fg[0], hl.fg[1], hl.fg[2], - false, hl.bold || false, false, - bg[0], bg[1], bg[2], - hl.extend_to_line_end || false - ); - } - // Step 2: Create NEW pane in a vertical split (RIGHT of OLD) - const newRes = await editor.createVirtualBufferInSplit({ - name: `[NEW] ${h.file}`, - mode: "diff-view", + const newBufferId = await editor.createVirtualBuffer({ + name: `*NEW:${h.file}*`, + mode: "normal", read_only: true, - editing_disabled: true, - entries: newPane.entries, - ratio: 0.5, - direction: "vertical", - show_line_numbers: false, - line_wrap: false + entries: newEntries, + show_line_numbers: true, + editing_disabled: true }); - const newBufferId = newRes.buffer_id; - const newSplitId = newRes.split_id!; - - // Apply highlights to new pane - editor.clearNamespace(newBufferId, "diff-view"); - for (const hl of newPane.highlights) { - const bg = hl.bg || [-1, -1, -1]; - editor.addOverlay( - newBufferId, "diff-view", - hl.range[0], hl.range[1], - hl.fg[0], hl.fg[1], hl.fg[2], - false, hl.bold || false, false, - bg[0], bg[1], bg[2], - hl.extend_to_line_end || false - ); - } - // Focus OLD pane (left) - convention is to start on old side - (editor as any).focusSplit(oldSplitId); - - // Set up core-handled scroll sync using the new anchor-based API - // This replaces the old viewport_changed hook approach - let scrollSyncGroupId: number | null = null; - try { - // Generate a unique group ID - scrollSyncGroupId = nextScrollSyncGroupId++; - (editor as any).createScrollSyncGroup(scrollSyncGroupId, oldSplitId, newSplitId); - - // Compute sync anchors from aligned lines - // Each aligned line is a sync point - we map line indices to anchors - // For the new core sync, we use line numbers (not byte offsets) - const anchors: [number, number][] = []; - for (let i = 0; i < alignedLines.length; i++) { - // Add anchors at meaningful boundaries: start of file, and at change boundaries - const line = alignedLines[i]; - const prevLine = i > 0 ? alignedLines[i - 1] : null; - - // Add anchor at start of file - if (i === 0) { - anchors.push([0, 0]); - } - - // Add anchor at change boundaries (when change type changes) - if (prevLine && prevLine.changeType !== line.changeType) { - anchors.push([i, i]); - } - } - - // Add anchor at end - if (alignedLines.length > 0) { - anchors.push([alignedLines.length, alignedLines.length]); + // Convert hunks to composite buffer format (parse counts from git diff) + const compositeHunks: TsCompositeHunk[] = fileHunks.map(fh => { + // Parse actual counts from the hunk lines + let oldCount = 0, newCount = 0; + for (const line of fh.lines) { + if (line.startsWith('-')) oldCount++; + else if (line.startsWith('+')) newCount++; + else if (line.startsWith(' ')) { oldCount++; newCount++; } } + return { + old_start: fh.oldRange.start - 1, // Convert to 0-indexed + old_count: oldCount || 1, + new_start: fh.range.start - 1, // Convert to 0-indexed + new_count: newCount || 1 + }; + }); - (editor as any).setScrollSyncAnchors(scrollSyncGroupId, anchors); - } catch (e) { - editor.debug(`Failed to create scroll sync group: ${e}`); - scrollSyncGroupId = null; - } + // Create composite buffer with side-by-side layout + const compositeBufferId = await editor.createCompositeBuffer({ + name: `*Diff: ${h.file}*`, + mode: "diff-view", + layout: { + layout_type: "side-by-side", + ratios: [0.5, 0.5], + show_separator: true + }, + sources: [ + { + buffer_id: oldBufferId, + label: "OLD (HEAD)", + editable: false, + style: { + remove_bg: [80, 40, 40], + gutter_style: "diff-markers" + } + }, + { + buffer_id: newBufferId, + label: "NEW (Working)", + editable: false, + style: { + add_bg: [40, 80, 40], + gutter_style: "diff-markers" + } + } + ], + hunks: compositeHunks.length > 0 ? compositeHunks : null + }); - // Store state for synchronized scrolling - activeSideBySideState = { - oldSplitId, - newSplitId, + // Store state for cleanup + activeCompositeDiffState = { + compositeBufferId, oldBufferId, newBufferId, - alignedLines, - oldLineByteOffsets: oldPane.lineByteOffsets, - newLineByteOffsets: newPane.lineByteOffsets, - scrollSyncGroupId + filePath: h.file }; - activeDiffViewState = { lSplit: oldSplitId, rSplit: newSplitId }; - const addedLines = alignedLines.filter(l => l.changeType === 'added').length; - const removedLines = alignedLines.filter(l => l.changeType === 'removed').length; - const modifiedLines = alignedLines.filter(l => l.changeType === 'modified').length; + // Show the composite buffer (replaces the review diff buffer) + editor.showBuffer(compositeBufferId); + + const addedCount = fileHunks.reduce((sum, fh) => { + return sum + fh.lines.filter(l => l.startsWith('+')).length; + }, 0); + const removedCount = fileHunks.reduce((sum, fh) => { + return sum + fh.lines.filter(l => l.startsWith('-')).length; + }, 0); + editor.setStatus(editor.t("status.diff_summary", { added: String(addedLines), removed: String(removedLines), modified: String(modifiedLines) })); } }; -// Define the diff-view mode with navigation keys -editor.defineMode("diff-view", "special", [ - ["q", "close_buffer"], - ["j", "move_down"], - ["k", "move_up"], - ["g", "move_document_start"], - ["G", "move_document_end"], - ["C-d", "move_page_down"], - ["C-u", "move_page_up"], - ["Down", "move_down"], - ["Up", "move_up"], - ["PageDown", "move_page_down"], - ["PageUp", "move_page_up"], +// Define the diff-view mode - inherits from "normal" for all standard navigation/selection/copy +// Only adds diff-specific keybindings (close, hunk navigation) +editor.defineMode("diff-view", "normal", [ + // Close the diff view + ["q", "close"], + // Hunk navigation (diff-specific) + ["n", "review_next_hunk"], + ["p", "review_prev_hunk"], + ["]", "review_next_hunk"], + ["[", "review_prev_hunk"], ], true); // --- Review Comment Actions --- @@ -1528,7 +1513,7 @@ globalThis.on_review_buffer_closed = (data: any) => { if (data.buffer_id === state.reviewBufferId) stop_review_diff(); }; -// Side-by-side diff for current file (can be called directly without Review Diff mode) +// Side-by-side diff for current file using composite buffers globalThis.side_by_side_diff_current_file = async () => { const bid = editor.getActiveBufferId(); const absolutePath = editor.getBufferPath(bid); @@ -1584,20 +1569,22 @@ globalThis.side_by_side_diff_current_file = async () => { for (const line of lines) { if (line.startsWith('@@')) { - const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@(.*)/); + const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)/); if (match) { const oldStart = parseInt(match[1]); - const newStart = parseInt(match[2]); + const oldCount = match[2] ? parseInt(match[2]) : 1; + const newStart = parseInt(match[3]); + const newCount = match[4] ? parseInt(match[4]) : 1; currentHunk = { id: `${filePath}:${newStart}`, file: filePath, - range: { start: newStart, end: newStart }, - oldRange: { start: oldStart, end: oldStart }, + range: { start: newStart, end: newStart + newCount - 1 }, + oldRange: { start: oldStart, end: oldStart + oldCount - 1 }, type: 'modify', lines: [], status: 'pending', reviewStatus: 'pending', - contextHeader: match[3]?.trim() || "", + contextHeader: match[5]?.trim() || "", byteOffset: 0 }; fileHunks.push(currentHunk); @@ -1631,13 +1618,6 @@ globalThis.side_by_side_diff_current_file = async () => { return; } - // Compute aligned diff for the FULL file - const alignedLines = computeFullFileAlignedDiff(oldContent, newContent, fileHunks); - - // Generate content for both panes - const oldPane = generateDiffPaneContent(alignedLines, 'old'); - const newPane = generateDiffPaneContent(alignedLines, 'new'); - // Close any existing side-by-side views if (activeSideBySideState) { try { @@ -1650,108 +1630,107 @@ globalThis.side_by_side_diff_current_file = async () => { activeSideBySideState = null; } - // Get the current split ID - const currentSplitId = (editor as any).getActiveSplitId(); + // Close any existing composite diff view + if (activeCompositeDiffState) { + try { + editor.closeCompositeBuffer(activeCompositeDiffState.compositeBufferId); + editor.closeBuffer(activeCompositeDiffState.oldBufferId); + editor.closeBuffer(activeCompositeDiffState.newBufferId); + } catch {} + activeCompositeDiffState = null; + } - // Create OLD buffer in the CURRENT split - const oldBufferId = await editor.createVirtualBufferInExistingSplit({ - name: `[OLD] ${filePath}`, - mode: "diff-view", - read_only: true, - editing_disabled: true, - entries: oldPane.entries, - split_id: currentSplitId, - show_line_numbers: false, - line_wrap: false - }); - const oldSplitId = currentSplitId; + // Create virtual buffers for old and new content + const oldLines = oldContent.split('\n'); + const newLines = newContent.split('\n'); - // Apply highlights to old pane - editor.clearNamespace(oldBufferId, "diff-view"); - for (const hl of oldPane.highlights) { - const bg = hl.bg || [-1, -1, -1]; - editor.addOverlay( - oldBufferId, "diff-view", - hl.range[0], hl.range[1], - hl.fg[0], hl.fg[1], hl.fg[2], - false, hl.bold || false, false, - bg[0], bg[1], bg[2], - hl.extend_to_line_end || false - ); - } + const oldEntries: TextPropertyEntry[] = oldLines.map((line, idx) => ({ + text: line + '\n', + properties: { type: 'line', lineNum: idx + 1 } + })); - // Create NEW pane in a vertical split (RIGHT of OLD) - const newRes = await editor.createVirtualBufferInSplit({ - name: `[NEW] ${filePath}`, - mode: "diff-view", + const newEntries: TextPropertyEntry[] = newLines.map((line, idx) => ({ + text: line + '\n', + properties: { type: 'line', lineNum: idx + 1 } + })); + + // Create source buffers (hidden, used by composite) + const oldBufferId = await editor.createVirtualBuffer({ + name: `*OLD:${filePath}*`, + mode: "normal", read_only: true, - editing_disabled: true, - entries: newPane.entries, - ratio: 0.5, - direction: "vertical", - show_line_numbers: false, - line_wrap: false + entries: oldEntries, + show_line_numbers: true, + editing_disabled: true }); - const newBufferId = newRes.buffer_id; - const newSplitId = newRes.split_id!; - // Apply highlights to new pane - editor.clearNamespace(newBufferId, "diff-view"); - for (const hl of newPane.highlights) { - const bg = hl.bg || [-1, -1, -1]; - editor.addOverlay( - newBufferId, "diff-view", - hl.range[0], hl.range[1], - hl.fg[0], hl.fg[1], hl.fg[2], - false, hl.bold || false, false, - bg[0], bg[1], bg[2], - hl.extend_to_line_end || false - ); - } - - // Focus OLD pane (left) - (editor as any).focusSplit(oldSplitId); + const newBufferId = await editor.createVirtualBuffer({ + name: `*NEW:${filePath}*`, + mode: "normal", + read_only: true, + entries: newEntries, + show_line_numbers: true, + editing_disabled: true + }); - // Set up scroll sync - let scrollSyncGroupId: number | null = null; - try { - scrollSyncGroupId = nextScrollSyncGroupId++; - (editor as any).createScrollSyncGroup(scrollSyncGroupId, oldSplitId, newSplitId); - - const anchors: [number, number][] = []; - for (let i = 0; i < alignedLines.length; i++) { - const line = alignedLines[i]; - const prevLine = i > 0 ? alignedLines[i - 1] : null; - if (i === 0) anchors.push([0, 0]); - if (prevLine && prevLine.changeType !== line.changeType) { - anchors.push([i, i]); + // Convert hunks to composite buffer format + const compositeHunks: TsCompositeHunk[] = fileHunks.map(h => ({ + old_start: h.oldRange.start - 1, // Convert to 0-indexed + old_count: h.oldRange.end - h.oldRange.start + 1, + new_start: h.range.start - 1, // Convert to 0-indexed + new_count: h.range.end - h.range.start + 1 + })); + + // Create composite buffer with side-by-side layout + const compositeBufferId = await editor.createCompositeBuffer({ + name: `*Diff: ${filePath}*`, + mode: "diff-view", + layout: { + layout_type: "side-by-side", + ratios: [0.5, 0.5], + show_separator: true + }, + sources: [ + { + buffer_id: oldBufferId, + label: "OLD (HEAD)", + editable: false, + style: { + remove_bg: [80, 40, 40], + gutter_style: "diff-markers" + } + }, + { + buffer_id: newBufferId, + label: "NEW (Working)", + editable: false, + style: { + add_bg: [40, 80, 40], + gutter_style: "diff-markers" + } } - } - if (alignedLines.length > 0) { - anchors.push([alignedLines.length, alignedLines.length]); - } - (editor as any).setScrollSyncAnchors(scrollSyncGroupId, anchors); - } catch (e) { - editor.debug(`Failed to create scroll sync group: ${e}`); - scrollSyncGroupId = null; - } + ], + hunks: compositeHunks.length > 0 ? compositeHunks : null + }); - // Store state - activeSideBySideState = { - oldSplitId, - newSplitId, + // Store state for cleanup + activeCompositeDiffState = { + compositeBufferId, oldBufferId, newBufferId, - alignedLines, - oldLineByteOffsets: oldPane.lineByteOffsets, - newLineByteOffsets: newPane.lineByteOffsets, - scrollSyncGroupId + filePath }; - activeDiffViewState = { lSplit: oldSplitId, rSplit: newSplitId }; - const addedLines = alignedLines.filter(l => l.changeType === 'added').length; - const removedLines = alignedLines.filter(l => l.changeType === 'removed').length; - const modifiedLines = alignedLines.filter(l => l.changeType === 'modified').length; + // Show the composite buffer + editor.showBuffer(compositeBufferId); + + const addedCount = fileHunks.reduce((sum, h) => { + return sum + h.lines.filter(l => l.startsWith('+')).length; + }, 0); + const removedCount = fileHunks.reduce((sum, h) => { + return sum + h.lines.filter(l => l.startsWith('-')).length; + }, 0); + editor.setStatus(editor.t("status.diff_summary", { added: String(addedLines), removed: String(removedLines), modified: String(modifiedLines) })); }; @@ -1772,7 +1751,7 @@ editor.registerCommand("%cmd.overall_feedback", "%cmd.overall_feedback_desc", "r editor.registerCommand("%cmd.export_markdown", "%cmd.export_markdown_desc", "review_export_session", "review-mode"); editor.registerCommand("%cmd.export_json", "%cmd.export_json_desc", "review_export_json", "review-mode"); -// Handler for when buffers are closed - cleans up scroll sync groups +// Handler for when buffers are closed - cleans up scroll sync groups and composite buffers globalThis.on_buffer_closed = (data: any) => { // If one of the diff view buffers is closed, clean up the scroll sync group if (activeSideBySideState) { @@ -1788,6 +1767,18 @@ globalThis.on_buffer_closed = (data: any) => { activeDiffViewState = null; } } + + // Clean up composite diff state if the composite buffer is closed + if (activeCompositeDiffState) { + if (data.buffer_id === activeCompositeDiffState.compositeBufferId) { + // Close the source buffers + try { + editor.closeBuffer(activeCompositeDiffState.oldBufferId); + editor.closeBuffer(activeCompositeDiffState.newBufferId); + } catch {} + activeCompositeDiffState = null; + } + } }; editor.on("buffer_closed", "on_buffer_closed"); @@ -1797,7 +1788,7 @@ editor.defineMode("review-mode", "normal", [ ["s", "review_stage_hunk"], ["d", "review_discard_hunk"], // Navigation ["n", "review_next_hunk"], ["p", "review_prev_hunk"], ["r", "review_refresh"], - ["Enter", "review_drill_down"], ["q", "close_buffer"], + ["Enter", "review_drill_down"], ["q", "close"], // Review actions ["c", "review_add_comment"], ["a", "review_approve_hunk"], diff --git a/plugins/lib/fresh.d.ts b/plugins/lib/fresh.d.ts index d578ecf42..f37742f2d 100644 --- a/plugins/lib/fresh.d.ts +++ b/plugins/lib/fresh.d.ts @@ -298,6 +298,68 @@ interface CreateVirtualBufferInCurrentSplitOptions { editing_disabled?: boolean | null; } +/** Layout configuration for composite buffers */ +interface TsCompositeLayoutConfig { + /** Layout type: "side-by-side", "stacked", or "unified" */ + layout_type: string; + /** Relative widths for side-by-side layout (e.g., [0.5, 0.5]) */ + ratios?: number[] | null; + /** Show separator between panes */ + show_separator?: boolean | null; + /** Spacing between stacked panes */ + spacing?: u16 | null; +} + +/** Pane style configuration */ +interface TsCompositePaneStyle { + /** Background color for added lines (RGB tuple) */ + add_bg?: [number, number, number] | null; + /** Background color for removed lines (RGB tuple) */ + remove_bg?: [number, number, number] | null; + /** Background color for modified lines (RGB tuple) */ + modify_bg?: [number, number, number] | null; + /** Gutter style: "line-numbers", "diff-markers", "both", "none" */ + gutter_style?: string | null; +} + +/** Source pane configuration for composite buffers */ +interface TsCompositeSourceConfig { + /** Buffer ID to display in this pane */ + buffer_id: number; + /** Label for the pane (shown in header) */ + label?: string | null; + /** Whether the pane is editable */ + editable: boolean; + /** Pane styling options */ + style?: TsCompositePaneStyle | null; +} + +/** Diff hunk configuration */ +interface TsCompositeHunk { + /** Start line in old file (0-indexed) */ + old_start: number; + /** Number of lines in old file */ + old_count: number; + /** Start line in new file (0-indexed) */ + new_start: number; + /** Number of lines in new file */ + new_count: number; +} + +/** Options for creating a composite buffer */ +interface CreateCompositeBufferOptions { + /** Display name for the composite buffer (shown in tab) */ + name: string; + /** Mode for keybindings (e.g., "diff-view") */ + mode: string; + /** Layout configuration */ + layout: TsCompositeLayoutConfig; + /** Source panes to display */ + sources: TsCompositeSourceConfig[]; + /** Optional diff hunks for line alignment */ + hunks?: TsCompositeHunk[] | null; +} + /** JavaScript representation of ActionSpec (with optional count) */ interface ActionSpecJs { action: string; @@ -732,6 +794,27 @@ interface EditorAPI { * @returns true if prompt was started successfully */ startPromptWithInitial(label: string, prompt_type: string, initial_value: string): boolean; + /** + * Create a composite buffer that displays multiple source buffers + * + * Composite buffers allow displaying multiple underlying buffers in a single + * tab/view area with custom layouts (side-by-side, stacked, unified). + * This is useful for diff views, merge conflict resolution, etc. + * @param options - Configuration for the composite buffer + * @returns Promise resolving to the buffer ID of the created composite buffer + */ + createCompositeBuffer(options: CreateCompositeBufferOptions): Promise; + /** + * Update line alignment for a composite buffer + * @param buffer_id - The composite buffer ID + * @param hunks - New diff hunks for alignment + */ + updateCompositeAlignment(buffer_id: number, hunks: TsCompositeHunk[]): boolean; + /** + * Close a composite buffer + * @param buffer_id - The composite buffer ID to close + */ + closeCompositeBuffer(buffer_id: number): boolean; /** * Send an arbitrary LSP request and receive the raw JSON response * @param language - Language ID (e.g., "cpp") diff --git a/src/app/composite_buffer_actions.rs b/src/app/composite_buffer_actions.rs new file mode 100644 index 000000000..076fe0fdb --- /dev/null +++ b/src/app/composite_buffer_actions.rs @@ -0,0 +1,559 @@ +//! Composite buffer management actions +//! +//! This module handles creating, managing, and closing composite buffers +//! which display multiple source buffers in a single tab. + +use crate::app::types::BufferMetadata; +use crate::app::Editor; +use crate::model::composite_buffer::{CompositeBuffer, CompositeLayout, LineAlignment, SourcePane}; +use crate::model::event::{BufferId, SplitId}; +use crate::view::composite_view::CompositeViewState; + +impl Editor { + // ========================================================================= + // Composite Buffer Methods + // ========================================================================= + + /// Check if a buffer is a composite buffer + pub fn is_composite_buffer(&self, buffer_id: BufferId) -> bool { + self.composite_buffers.contains_key(&buffer_id) + } + + /// Get a composite buffer by ID + pub fn get_composite(&self, buffer_id: BufferId) -> Option<&CompositeBuffer> { + self.composite_buffers.get(&buffer_id) + } + + /// Get a mutable composite buffer by ID + pub fn get_composite_mut(&mut self, buffer_id: BufferId) -> Option<&mut CompositeBuffer> { + self.composite_buffers.get_mut(&buffer_id) + } + + /// Get or create composite view state for a split + pub fn get_composite_view_state( + &mut self, + split_id: SplitId, + buffer_id: BufferId, + ) -> Option<&mut CompositeViewState> { + if !self.composite_buffers.contains_key(&buffer_id) { + return None; + } + + let pane_count = self.composite_buffers.get(&buffer_id)?.pane_count(); + + Some( + self.composite_view_states + .entry((split_id, buffer_id)) + .or_insert_with(|| CompositeViewState::new(buffer_id, pane_count)), + ) + } + + /// Create a new composite buffer + /// + /// # Arguments + /// * `name` - Display name for the composite buffer (shown in tab) + /// * `mode` - Mode for keybindings (e.g., "diff-view") + /// * `layout` - How panes are arranged (side-by-side, stacked, unified) + /// * `sources` - Source panes to display + /// + /// # Returns + /// The ID of the newly created composite buffer + pub fn create_composite_buffer( + &mut self, + name: String, + mode: String, + layout: CompositeLayout, + sources: Vec, + ) -> BufferId { + let buffer_id = BufferId(self.next_buffer_id); + self.next_buffer_id += 1; + + let composite = + CompositeBuffer::new(buffer_id, name.clone(), mode.clone(), layout, sources); + self.composite_buffers.insert(buffer_id, composite); + + // Add metadata for display + let metadata = BufferMetadata::virtual_buffer(name.clone(), mode.clone(), true); + self.buffer_metadata.insert(buffer_id, metadata); + + // Create an EditorState entry so the buffer can be shown in tabs and via showBuffer() + // The actual content rendering is handled by the composite renderer + let mut state = crate::state::EditorState::new( + 80, + 24, + crate::config::LARGE_FILE_THRESHOLD_BYTES as usize, + ); + state.is_composite_buffer = true; + state.editing_disabled = true; + state.mode = mode; + self.buffers.insert(buffer_id, state); + + // Create an event log entry (required for many editor operations) + self.event_logs + .insert(buffer_id, crate::model::event::EventLog::new()); + + // Register with the active split so it appears in tabs + let split_id = self.split_manager.active_split(); + if let Some(view_state) = self.split_view_states.get_mut(&split_id) { + view_state.add_buffer(buffer_id); + } + + buffer_id + } + + /// Set the line alignment for a composite buffer + /// + /// The alignment determines how lines from different source buffers + /// are paired up for display (important for diff views). + pub fn set_composite_alignment(&mut self, buffer_id: BufferId, alignment: LineAlignment) { + if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) { + composite.set_alignment(alignment); + } + } + + /// Close a composite buffer and clean up associated state + pub fn close_composite_buffer(&mut self, buffer_id: BufferId) { + self.composite_buffers.remove(&buffer_id); + self.buffer_metadata.remove(&buffer_id); + + // Remove all view states for this buffer + self.composite_view_states + .retain(|(_, bid), _| *bid != buffer_id); + } + + /// Switch focus to the next pane in a composite buffer + pub fn composite_focus_next(&mut self, buffer_id: BufferId) { + if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) { + composite.focus_next(); + } + } + + /// Switch focus to the previous pane in a composite buffer + pub fn composite_focus_prev(&mut self, buffer_id: BufferId) { + if let Some(composite) = self.composite_buffers.get_mut(&buffer_id) { + composite.focus_prev(); + } + } + + /// Navigate to the next hunk in a composite buffer's diff view + pub fn composite_next_hunk(&mut self, split_id: SplitId, buffer_id: BufferId) -> bool { + if let (Some(composite), Some(view_state)) = ( + self.composite_buffers.get(&buffer_id), + self.composite_view_states.get_mut(&(split_id, buffer_id)), + ) { + if let Some(next_row) = composite.alignment.next_hunk_row(view_state.scroll_row) { + view_state.scroll_row = next_row; + return true; + } + } + false + } + + /// Navigate to the previous hunk in a composite buffer's diff view + pub fn composite_prev_hunk(&mut self, split_id: SplitId, buffer_id: BufferId) -> bool { + if let (Some(composite), Some(view_state)) = ( + self.composite_buffers.get(&buffer_id), + self.composite_view_states.get_mut(&(split_id, buffer_id)), + ) { + if let Some(prev_row) = composite.alignment.prev_hunk_row(view_state.scroll_row) { + view_state.scroll_row = prev_row; + return true; + } + } + false + } + + /// Scroll a composite buffer view + pub fn composite_scroll(&mut self, split_id: SplitId, buffer_id: BufferId, delta: isize) { + if let (Some(composite), Some(view_state)) = ( + self.composite_buffers.get(&buffer_id), + self.composite_view_states.get_mut(&(split_id, buffer_id)), + ) { + let max_row = composite.row_count().saturating_sub(1); + view_state.scroll(delta, max_row); + } + } + + /// Scroll composite buffer to a specific row + pub fn composite_scroll_to(&mut self, split_id: SplitId, buffer_id: BufferId, row: usize) { + if let (Some(composite), Some(view_state)) = ( + self.composite_buffers.get(&buffer_id), + self.composite_view_states.get_mut(&(split_id, buffer_id)), + ) { + let max_row = composite.row_count().saturating_sub(1); + view_state.set_scroll_row(row, max_row); + } + } + + // ========================================================================= + // Action Handling for Composite Buffers + // ========================================================================= + + /// Handle an action for a composite buffer. + /// + /// For navigation and selection actions, this forwards to the focused source buffer + /// and syncs scroll between panes. Returns Some(true) if handled, None to fall through + /// to normal buffer handling. + pub fn handle_composite_action( + &mut self, + buffer_id: BufferId, + action: &crate::input::keybindings::Action, + ) -> Option { + use crate::input::keybindings::Action; + + let split_id = self.split_manager.active_split(); + + // Get the focused source buffer for forwarding actions + let (focused_buffer_id, focused_pane_idx, other_buffer_id) = { + let composite = self.composite_buffers.get(&buffer_id)?; + let view_state = self.composite_view_states.get(&(split_id, buffer_id))?; + let focused = composite.sources.get(view_state.focused_pane)?.buffer_id; + let other_pane = if view_state.focused_pane == 0 { 1 } else { 0 }; + let other = composite.sources.get(other_pane).map(|s| s.buffer_id); + (focused, view_state.focused_pane, other) + }; + let _ = focused_pane_idx; // Used for Copy action mapping + + // Actions that need special composite handling + match action { + // Copy from the focused pane (need to map aligned rows to source lines) + Action::Copy => { + if let (Some(composite), Some(view_state)) = ( + self.composite_buffers.get(&buffer_id), + self.composite_view_states.get(&(split_id, buffer_id)), + ) { + if let Some((start_row, end_row)) = view_state.selection_row_range() { + // Get the source buffer for the focused pane + if let Some(source) = composite.sources.get(view_state.focused_pane) { + if let Some(source_state) = self.buffers.get(&source.buffer_id) { + // Collect text from selected rows + let mut text = String::new(); + for row in start_row..=end_row { + // Map display row to source line + if let Some(aligned_row) = composite.alignment.rows.get(row) { + if let Some(line_ref) = + aligned_row.get_pane_line(view_state.focused_pane) + { + if let Some(line_bytes) = + source_state.buffer.get_line(line_ref.line) + { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&String::from_utf8_lossy( + &line_bytes, + )); + } + } + } + } + // Copy to clipboard + if !text.is_empty() { + self.clipboard.copy(text); + } + } + } + // Clear selection after copy + if let Some(view_state) = + self.composite_view_states.get_mut(&(split_id, buffer_id)) + { + view_state.clear_selection(); + } + } + } + Some(true) + } + + // Navigation: Update composite view's cursor/scroll position + // These operate on the aligned view, not the underlying source buffers + Action::MoveDown | Action::MoveUp | Action::MoveLeft | Action::MoveRight => { + let viewport_height = self + .split_view_states + .get(&split_id) + .map(|vs| vs.viewport.height as usize) + .unwrap_or(24); + + let new_cursor_row; + let new_cursor_column; + + if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) + { + match action { + Action::MoveDown => { + if let Some(composite) = self.composite_buffers.get(&buffer_id) { + let max_row = composite.row_count().saturating_sub(1); + view_state.move_cursor_down(max_row, viewport_height); + } + } + Action::MoveUp => view_state.move_cursor_up(), + Action::MoveLeft => { + view_state.cursor_column = view_state.cursor_column.saturating_sub(1); + } + Action::MoveRight => { + view_state.cursor_column = view_state.cursor_column.saturating_add(1); + } + _ => {} + } + new_cursor_row = view_state.cursor_row; + new_cursor_column = view_state.cursor_column; + } else { + new_cursor_row = 0; + new_cursor_column = 0; + } + + // Sync the fake EditorState's cursor with CompositeViewState + // This makes the status bar show the correct position + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state.primary_cursor_line_number = + crate::model::buffer::LineNumber::Absolute(new_cursor_row); + state.cursors.primary_mut().position = new_cursor_column; + } + + let _ = (focused_buffer_id, other_buffer_id); + Some(true) + } + + // Page navigation + Action::MovePageDown | Action::MovePageUp => { + let viewport_height = self + .split_view_states + .get(&split_id) + .map(|vs| vs.viewport.height as usize) + .unwrap_or(24); + + if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) + { + if matches!(action, Action::MovePageDown) { + if let Some(composite) = self.composite_buffers.get(&buffer_id) { + let max_row = composite.row_count().saturating_sub(1); + view_state.page_down(viewport_height, max_row); + view_state.cursor_row = view_state.scroll_row; + } + } else { + view_state.page_up(viewport_height); + view_state.cursor_row = view_state.scroll_row; + } + } + + let _ = (focused_buffer_id, other_buffer_id); + Some(true) + } + + // Document start/end + Action::MoveDocumentStart | Action::MoveDocumentEnd => { + let viewport_height = self + .split_view_states + .get(&split_id) + .map(|vs| vs.viewport.height as usize) + .unwrap_or(24); + + if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) + { + if matches!(action, Action::MoveDocumentStart) { + view_state.move_cursor_to_top(); + } else if let Some(composite) = self.composite_buffers.get(&buffer_id) { + let max_row = composite.row_count().saturating_sub(1); + view_state.move_cursor_to_bottom(max_row, viewport_height); + } + } + + let _ = (focused_buffer_id, other_buffer_id); + Some(true) + } + + // Scroll without moving cursor + Action::ScrollDown | Action::ScrollUp => { + let delta = if matches!(action, Action::ScrollDown) { + 1 + } else { + -1 + }; + self.composite_scroll(split_id, buffer_id, delta); + + let _ = (focused_buffer_id, other_buffer_id); + Some(true) + } + + // Selection: Start visual mode and extend + Action::SelectDown | Action::SelectUp | Action::SelectLeft | Action::SelectRight => { + let viewport_height = self + .split_view_states + .get(&split_id) + .map(|vs| vs.viewport.height as usize) + .unwrap_or(24); + + let new_cursor_row; + let new_cursor_column; + + if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) + { + if !view_state.visual_mode { + view_state.start_visual_selection(); + } + match action { + Action::SelectDown => { + if let Some(composite) = self.composite_buffers.get(&buffer_id) { + let max_row = composite.row_count().saturating_sub(1); + view_state.move_cursor_down(max_row, viewport_height); + } + } + Action::SelectUp => view_state.move_cursor_up(), + Action::SelectLeft => { + view_state.cursor_column = view_state.cursor_column.saturating_sub(1); + } + Action::SelectRight => { + view_state.cursor_column = view_state.cursor_column.saturating_add(1); + } + _ => {} + } + new_cursor_row = view_state.cursor_row; + new_cursor_column = view_state.cursor_column; + } else { + new_cursor_row = 0; + new_cursor_column = 0; + } + + // Sync the fake EditorState's cursor with CompositeViewState + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state.primary_cursor_line_number = + crate::model::buffer::LineNumber::Absolute(new_cursor_row); + state.cursors.primary_mut().position = new_cursor_column; + } + + let _ = (focused_buffer_id, other_buffer_id); + Some(true) + } + + // For other actions, return None to fall through to normal handling + _ => None, + } + } + + // ========================================================================= + // Plugin Command Handlers + // ========================================================================= + + /// Handle the CreateCompositeBuffer plugin command + pub(crate) fn handle_create_composite_buffer( + &mut self, + name: String, + mode: String, + layout_config: crate::services::plugins::api::CompositeLayoutConfig, + source_configs: Vec, + hunks: Option>, + _request_id: Option, + ) { + use crate::model::composite_buffer::{ + CompositeLayout, DiffHunk, GutterStyle, LineAlignment, PaneStyle, SourcePane, + }; + + // Convert layout config + let layout = match layout_config.layout_type.as_str() { + "stacked" => CompositeLayout::Stacked { + spacing: layout_config.spacing.unwrap_or(1), + }, + "unified" => CompositeLayout::Unified, + _ => CompositeLayout::SideBySide { + ratios: layout_config.ratios.unwrap_or_else(|| vec![0.5, 0.5]), + show_separator: layout_config.show_separator, + }, + }; + + // Convert source configs + let sources: Vec = source_configs + .into_iter() + .map(|src| { + let mut pane = SourcePane::new(BufferId(src.buffer_id), src.label, src.editable); + if let Some(style_config) = src.style { + let gutter_style = match style_config.gutter_style.as_deref() { + Some("diff-markers") => GutterStyle::DiffMarkers, + Some("both") => GutterStyle::Both, + Some("none") => GutterStyle::None, + _ => GutterStyle::LineNumbers, + }; + pane.style = PaneStyle { + add_bg: style_config.add_bg, + remove_bg: style_config.remove_bg, + modify_bg: style_config.modify_bg, + gutter_style, + }; + } + pane + }) + .collect(); + + // Create the composite buffer + let buffer_id = self.create_composite_buffer(name.clone(), mode.clone(), layout, sources); + + // Set alignment from hunks if provided + if let Some(hunk_configs) = hunks { + let diff_hunks: Vec = hunk_configs + .into_iter() + .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)) + .collect(); + + // Get line counts from source buffers + let old_line_count = self + .buffers + .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[0].buffer_id) + .and_then(|s| s.buffer.line_count()) + .unwrap_or(0); + let new_line_count = self + .buffers + .get(&self.composite_buffers.get(&buffer_id).unwrap().sources[1].buffer_id) + .and_then(|s| s.buffer.line_count()) + .unwrap_or(0); + + let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count); + self.set_composite_alignment(buffer_id, alignment); + } + + tracing::info!( + "Created composite buffer '{}' with mode '{}' (id={:?})", + name, + mode, + buffer_id + ); + + // Send response with buffer_id if request_id is provided + if let Some(req_id) = _request_id { + self.send_plugin_response( + crate::services::plugins::api::PluginResponse::CompositeBufferCreated { + request_id: req_id, + buffer_id, + }, + ); + } + } + + /// Handle the UpdateCompositeAlignment plugin command + pub(crate) fn handle_update_composite_alignment( + &mut self, + buffer_id: BufferId, + hunk_configs: Vec, + ) { + use crate::model::composite_buffer::{DiffHunk, LineAlignment}; + + if let Some(composite) = self.composite_buffers.get(&buffer_id) { + let diff_hunks: Vec = hunk_configs + .into_iter() + .map(|h| DiffHunk::new(h.old_start, h.old_count, h.new_start, h.new_count)) + .collect(); + + // Get line counts from source buffers + let old_line_count = self + .buffers + .get(&composite.sources[0].buffer_id) + .and_then(|s| s.buffer.line_count()) + .unwrap_or(0); + let new_line_count = self + .buffers + .get(&composite.sources[1].buffer_id) + .and_then(|s| s.buffer.line_count()) + .unwrap_or(0); + + let alignment = LineAlignment::from_hunks(&diff_hunks, old_line_count, new_line_count); + self.set_composite_alignment(buffer_id, alignment); + } + } +} diff --git a/src/app/input.rs b/src/app/input.rs index 8821f19db..b2d6914f0 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -338,7 +338,16 @@ impl Editor { ); } } - Action::Copy => self.copy_selection(), + Action::Copy => { + // Check if active buffer is a composite buffer + let buffer_id = self.active_buffer(); + if self.is_composite_buffer(buffer_id) { + if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) { + return Ok(()); + } + } + self.copy_selection() + } Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme), Action::Cut => { if self.is_editing_disabled() { @@ -2488,6 +2497,14 @@ impl Editor { /// (cursor movements, text edits, etc.). It handles batching for multi-cursor, /// position history tracking, and editing permission checks. fn apply_action_as_events(&mut self, action: Action) -> std::io::Result<()> { + // Check if active buffer is a composite buffer - handle scroll/movement specially + let buffer_id = self.active_buffer(); + if self.is_composite_buffer(buffer_id) { + if let Some(handled) = self.handle_composite_action(buffer_id, &action) { + return if handled { Ok(()) } else { Ok(()) }; + } + } + // Get description before moving action let action_description = format!("{:?}", action); diff --git a/src/app/mod.rs b/src/app/mod.rs index 68a1ab04c..8b7ca796e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,6 +3,7 @@ mod buffer_management; mod calibration_actions; pub mod calibration_wizard; mod clipboard; +mod composite_buffer_actions; mod file_explorer; pub mod file_open; mod file_open_input; @@ -537,6 +538,15 @@ pub struct Editor { /// Stores (popup_id, Vec<(action_id, action_label)>) active_action_popup: Option<(String, Vec<(String, String)>)>, + /// Composite buffers (separate from regular buffers) + /// These display multiple source buffers in a single tab + composite_buffers: HashMap, + + /// View state for composite buffers (per split) + /// Maps (split_id, buffer_id) to composite view state + composite_view_states: + HashMap<(SplitId, BufferId), crate::view::composite_view::CompositeViewState>, + /// Stdin streaming state (if reading from stdin) stdin_streaming: Option, } @@ -976,6 +986,8 @@ impl Editor { stdin_streaming: None, review_hunks: Vec::new(), active_action_popup: None, + composite_buffers: HashMap::new(), + composite_view_states: HashMap::new(), }) } @@ -1560,6 +1572,11 @@ impl Editor { /// Get the display name for a buffer (filename or virtual buffer name) pub fn get_buffer_display_name(&self, buffer_id: BufferId) -> String { + // Check composite buffers first + if let Some(composite) = self.composite_buffers.get(&buffer_id) { + return composite.name.clone(); + } + self.buffer_metadata .get(&buffer_id) .map(|m| m.display_name.clone()) @@ -4212,6 +4229,24 @@ impl Editor { tracing::warn!("Scroll sync group {} not found", group_id); } } + + // ==================== Composite Buffer Commands ==================== + PluginCommand::CreateCompositeBuffer { + name, + mode, + layout, + sources, + hunks, + request_id, + } => { + self.handle_create_composite_buffer(name, mode, layout, sources, hunks, request_id); + } + PluginCommand::UpdateCompositeAlignment { buffer_id, hunks } => { + self.handle_update_composite_alignment(buffer_id, hunks); + } + PluginCommand::CloseCompositeBuffer { buffer_id } => { + self.close_composite_buffer(buffer_id); + } } Ok(()) } diff --git a/src/app/render.rs b/src/app/render.rs index ad94583a4..924deb619 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -332,6 +332,8 @@ impl Editor { &mut self.buffers, &self.buffer_metadata, &mut self.event_logs, + &self.composite_buffers, + &mut self.composite_view_states, &self.theme, self.ansi_background.as_ref(), self.background_fade, diff --git a/src/input/composite_router.rs b/src/input/composite_router.rs new file mode 100644 index 000000000..9ca80bb0f --- /dev/null +++ b/src/input/composite_router.rs @@ -0,0 +1,372 @@ +//! Input routing for composite buffers +//! +//! Routes keyboard and mouse input to the appropriate source buffer +//! based on focus state and cursor position within the composite view. + +use crate::model::composite_buffer::CompositeBuffer; +use crate::model::event::BufferId; +use crate::view::composite_view::CompositeViewState; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Result of routing an input event +#[derive(Debug, Clone)] +pub enum RoutedEvent { + /// Event affects composite view scrolling + CompositeScroll(ScrollAction), + /// Switch focus to another pane + SwitchPane(Direction), + /// Navigate to next/previous hunk + NavigateHunk(Direction), + /// Route to a source buffer for editing + ToSourceBuffer { + buffer_id: BufferId, + action: BufferAction, + }, + /// Cursor movement within focused pane + PaneCursor(CursorAction), + /// Selection action + Selection(SelectionAction), + /// Yank/copy the selected text + Yank, + /// Event was blocked (e.g., editing read-only pane) + Blocked(&'static str), + /// Close the composite view + Close, + /// Event not handled by composite router + Unhandled, +} + +/// Selection actions for visual mode +#[derive(Debug, Clone, Copy)] +pub enum SelectionAction { + /// Start visual selection at current position + StartVisual, + /// Start line-wise visual selection + StartVisualLine, + /// Clear selection + ClearSelection, + /// Extend selection up + ExtendUp, + /// Extend selection down + ExtendDown, + /// Extend selection left + ExtendLeft, + /// Extend selection right + ExtendRight, +} + +/// Scroll actions for the composite view +#[derive(Debug, Clone, Copy)] +pub enum ScrollAction { + Up(usize), + Down(usize), + PageUp, + PageDown, + ToTop, + ToBottom, + ToRow(usize), +} + +/// Direction for navigation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Next, + Prev, +} + +/// Actions that modify buffer content +#[derive(Debug, Clone)] +pub enum BufferAction { + Insert(char), + InsertString(String), + Delete, + Backspace, + NewLine, +} + +/// Cursor movement actions +#[derive(Debug, Clone, Copy)] +pub enum CursorAction { + Up, + Down, + Left, + Right, + LineStart, + LineEnd, + WordLeft, + WordRight, +} + +/// Routes input events for a composite buffer +pub struct CompositeInputRouter; + +impl CompositeInputRouter { + /// Route a key event to the appropriate action + pub fn route_key_event( + composite: &CompositeBuffer, + view_state: &CompositeViewState, + event: &KeyEvent, + ) -> RoutedEvent { + let focused_pane = composite.sources.get(view_state.focused_pane); + + match (event.modifiers, event.code) { + // Scroll navigation + (KeyModifiers::NONE, KeyCode::Up) | (KeyModifiers::NONE, KeyCode::Char('k')) => { + RoutedEvent::CompositeScroll(ScrollAction::Up(1)) + } + (KeyModifiers::NONE, KeyCode::Down) | (KeyModifiers::NONE, KeyCode::Char('j')) => { + RoutedEvent::CompositeScroll(ScrollAction::Down(1)) + } + (KeyModifiers::CONTROL, KeyCode::Char('u')) => { + RoutedEvent::CompositeScroll(ScrollAction::PageUp) + } + (KeyModifiers::CONTROL, KeyCode::Char('d')) => { + RoutedEvent::CompositeScroll(ScrollAction::PageDown) + } + (KeyModifiers::NONE, KeyCode::PageUp) => { + RoutedEvent::CompositeScroll(ScrollAction::PageUp) + } + (KeyModifiers::NONE, KeyCode::PageDown) => { + RoutedEvent::CompositeScroll(ScrollAction::PageDown) + } + (KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::NONE, KeyCode::Char('g')) => { + RoutedEvent::CompositeScroll(ScrollAction::ToTop) + } + (KeyModifiers::SHIFT, KeyCode::Char('G')) | (KeyModifiers::NONE, KeyCode::End) => { + RoutedEvent::CompositeScroll(ScrollAction::ToBottom) + } + + // Pane switching + (KeyModifiers::NONE, KeyCode::Tab) => RoutedEvent::SwitchPane(Direction::Next), + (KeyModifiers::SHIFT, KeyCode::BackTab) => RoutedEvent::SwitchPane(Direction::Prev), + (KeyModifiers::NONE, KeyCode::Char('h')) => RoutedEvent::SwitchPane(Direction::Prev), + (KeyModifiers::NONE, KeyCode::Char('l')) => RoutedEvent::SwitchPane(Direction::Next), + + // Hunk navigation + (KeyModifiers::NONE, KeyCode::Char('n')) => RoutedEvent::NavigateHunk(Direction::Next), + (KeyModifiers::NONE, KeyCode::Char('p')) => RoutedEvent::NavigateHunk(Direction::Prev), + (KeyModifiers::NONE, KeyCode::Char(']')) => RoutedEvent::NavigateHunk(Direction::Next), + (KeyModifiers::NONE, KeyCode::Char('[')) => RoutedEvent::NavigateHunk(Direction::Prev), + + // Close + (KeyModifiers::NONE, KeyCode::Char('q')) | (KeyModifiers::NONE, KeyCode::Esc) => { + RoutedEvent::Close + } + + // Visual selection + (KeyModifiers::NONE, KeyCode::Char('v')) => { + RoutedEvent::Selection(SelectionAction::StartVisual) + } + (KeyModifiers::SHIFT, KeyCode::Char('V')) => { + RoutedEvent::Selection(SelectionAction::StartVisualLine) + } + + // Yank (copy) selected text + (KeyModifiers::NONE, KeyCode::Char('y')) => RoutedEvent::Yank, + + // Editing (if pane is editable) + (KeyModifiers::NONE, KeyCode::Char(c)) => { + if let Some(pane) = focused_pane { + if pane.editable { + RoutedEvent::ToSourceBuffer { + buffer_id: pane.buffer_id, + action: BufferAction::Insert(c), + } + } else { + RoutedEvent::Blocked("Pane is read-only") + } + } else { + RoutedEvent::Unhandled + } + } + (KeyModifiers::NONE, KeyCode::Backspace) => { + if let Some(pane) = focused_pane { + if pane.editable { + RoutedEvent::ToSourceBuffer { + buffer_id: pane.buffer_id, + action: BufferAction::Backspace, + } + } else { + RoutedEvent::Blocked("Pane is read-only") + } + } else { + RoutedEvent::Unhandled + } + } + (KeyModifiers::NONE, KeyCode::Delete) => { + if let Some(pane) = focused_pane { + if pane.editable { + RoutedEvent::ToSourceBuffer { + buffer_id: pane.buffer_id, + action: BufferAction::Delete, + } + } else { + RoutedEvent::Blocked("Pane is read-only") + } + } else { + RoutedEvent::Unhandled + } + } + (KeyModifiers::NONE, KeyCode::Enter) => { + if let Some(pane) = focused_pane { + if pane.editable { + RoutedEvent::ToSourceBuffer { + buffer_id: pane.buffer_id, + action: BufferAction::NewLine, + } + } else { + RoutedEvent::Blocked("Pane is read-only") + } + } else { + RoutedEvent::Unhandled + } + } + + // Cursor movement in focused pane + (KeyModifiers::NONE, KeyCode::Left) => RoutedEvent::PaneCursor(CursorAction::Left), + (KeyModifiers::NONE, KeyCode::Right) => RoutedEvent::PaneCursor(CursorAction::Right), + (KeyModifiers::CONTROL, KeyCode::Left) => { + RoutedEvent::PaneCursor(CursorAction::WordLeft) + } + (KeyModifiers::CONTROL, KeyCode::Right) => { + RoutedEvent::PaneCursor(CursorAction::WordRight) + } + + _ => RoutedEvent::Unhandled, + } + } + + /// Convert display coordinates to source buffer coordinates + pub fn display_to_source( + composite: &CompositeBuffer, + _view_state: &CompositeViewState, + display_row: usize, + display_col: usize, + pane_index: usize, + ) -> Option { + let aligned_row = composite.alignment.get_row(display_row)?; + let source_ref = aligned_row.get_pane_line(pane_index)?; + + Some(SourceCoordinate { + buffer_id: composite.sources.get(pane_index)?.buffer_id, + byte_offset: source_ref.byte_range.start + display_col, + line: source_ref.line, + column: display_col, + }) + } + + /// Determine which pane a click occurred in + pub fn click_to_pane( + view_state: &CompositeViewState, + click_x: u16, + area_x: u16, + ) -> Option { + let mut x = area_x; + for (i, &width) in view_state.pane_widths.iter().enumerate() { + if click_x >= x && click_x < x + width { + return Some(i); + } + x += width + 1; // +1 for separator + } + None + } + + /// Navigate to the next or previous hunk + pub fn navigate_to_hunk( + composite: &CompositeBuffer, + view_state: &mut CompositeViewState, + direction: Direction, + ) -> bool { + let current_row = view_state.scroll_row; + let new_row = match direction { + Direction::Next => composite.alignment.next_hunk_row(current_row), + Direction::Prev => composite.alignment.prev_hunk_row(current_row), + }; + + if let Some(row) = new_row { + view_state.scroll_row = row; + true + } else { + false + } + } +} + +/// Coordinates within a source buffer +#[derive(Debug, Clone)] +pub struct SourceCoordinate { + pub buffer_id: BufferId, + pub byte_offset: usize, + pub line: usize, + pub column: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::composite_buffer::{CompositeLayout, LineAlignment, PaneStyle, SourcePane}; + + fn create_test_composite() -> (CompositeBuffer, CompositeViewState) { + let sources = vec![ + SourcePane::new(BufferId(1), "OLD", false), + SourcePane::new(BufferId(2), "NEW", true), + ]; + let composite = CompositeBuffer::new( + BufferId(0), + "Test Diff".to_string(), + "diff-view".to_string(), + CompositeLayout::default(), + sources, + ); + let view_state = CompositeViewState::new(BufferId(0), 2); + (composite, view_state) + } + + #[test] + fn test_scroll_routing() { + let (composite, view_state) = create_test_composite(); + + let event = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event); + + matches!(result, RoutedEvent::CompositeScroll(ScrollAction::Down(1))); + } + + #[test] + fn test_pane_switch_routing() { + let (composite, view_state) = create_test_composite(); + + let event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event); + + matches!(result, RoutedEvent::SwitchPane(Direction::Next)); + } + + #[test] + fn test_readonly_blocking() { + let (composite, view_state) = create_test_composite(); + // Focused pane is 0 (OLD), which is read-only + + let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); + let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event); + + matches!(result, RoutedEvent::Blocked(_)); + } + + #[test] + fn test_editable_routing() { + let (composite, mut view_state) = create_test_composite(); + view_state.focused_pane = 1; // NEW pane is editable + + let event = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); + let result = CompositeInputRouter::route_key_event(&composite, &view_state, &event); + + matches!( + result, + RoutedEvent::ToSourceBuffer { + buffer_id: BufferId(2), + action: BufferAction::Insert('x'), + } + ); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 41e9b9504..9a578eae2 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -6,6 +6,7 @@ pub mod actions; pub mod buffer_mode; pub mod command_registry; pub mod commands; +pub mod composite_router; pub mod fuzzy; pub mod handler; pub mod input_history; diff --git a/src/model/composite_buffer.rs b/src/model/composite_buffer.rs new file mode 100644 index 000000000..e030952bd --- /dev/null +++ b/src/model/composite_buffer.rs @@ -0,0 +1,546 @@ +//! Composite buffer for displaying multiple source buffers in a single view +//! +//! A composite buffer synthesizes its view from multiple source buffers, +//! enabling side-by-side diff, unified diff, 3-way merge, and code review views +//! within a single tab. + +use crate::model::event::BufferId; +use serde::{Deserialize, Serialize}; +use std::ops::Range; + +/// A buffer that composes content from multiple source buffers +#[derive(Debug, Clone)] +pub struct CompositeBuffer { + /// Unique ID for this composite buffer + pub id: BufferId, + + /// Display name (shown in tab bar) + pub name: String, + + /// Layout mode for this composite + pub layout: CompositeLayout, + + /// Source buffer configurations + pub sources: Vec, + + /// Line alignment map (for side-by-side diff) + /// Maps display_line -> (left_source_line, right_source_line) + pub alignment: LineAlignment, + + /// Which pane currently has focus (for input routing) + pub active_pane: usize, + + /// Mode for keybindings + pub mode: String, +} + +impl CompositeBuffer { + /// Create a new composite buffer + pub fn new( + id: BufferId, + name: String, + mode: String, + layout: CompositeLayout, + sources: Vec, + ) -> Self { + let pane_count = sources.len(); + Self { + id, + name, + mode, + layout, + sources, + alignment: LineAlignment::empty(pane_count), + active_pane: 0, + } + } + + /// Get the number of source panes + pub fn pane_count(&self) -> usize { + self.sources.len() + } + + /// Get the source pane at the given index + pub fn get_pane(&self, index: usize) -> Option<&SourcePane> { + self.sources.get(index) + } + + /// Get the currently focused pane + pub fn focused_pane(&self) -> Option<&SourcePane> { + self.sources.get(self.active_pane) + } + + /// Switch focus to the next pane + pub fn focus_next(&mut self) { + if !self.sources.is_empty() { + self.active_pane = (self.active_pane + 1) % self.sources.len(); + } + } + + /// Switch focus to the previous pane + pub fn focus_prev(&mut self) { + if !self.sources.is_empty() { + self.active_pane = (self.active_pane + self.sources.len() - 1) % self.sources.len(); + } + } + + /// Set the line alignment + pub fn set_alignment(&mut self, alignment: LineAlignment) { + self.alignment = alignment; + } + + /// Get the total number of display rows + pub fn row_count(&self) -> usize { + self.alignment.rows.len() + } +} + +/// How the composite buffer arranges its source panes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CompositeLayout { + /// Side-by-side columns (for diff view) + SideBySide { + /// Width ratio for each pane (must sum to 1.0) + ratios: Vec, + /// Show separator between panes + show_separator: bool, + }, + /// Vertically stacked sections (for notebook cells) + Stacked { + /// Spacing between sections (in lines) + spacing: u16, + }, + /// Interleaved lines (for unified diff) + Unified, +} + +impl Default for CompositeLayout { + fn default() -> Self { + CompositeLayout::SideBySide { + ratios: vec![0.5, 0.5], + show_separator: true, + } + } +} + +/// Configuration for a single source pane within the composite +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourcePane { + /// ID of the source buffer + pub buffer_id: BufferId, + + /// Human-readable label (e.g., "OLD", "NEW", "BASE") + pub label: String, + + /// Whether this pane accepts edits + pub editable: bool, + + /// Visual style for this pane + pub style: PaneStyle, + + /// Byte range in source buffer to display (None = entire buffer) + pub range: Option>, +} + +impl SourcePane { + /// Create a new source pane + pub fn new(buffer_id: BufferId, label: impl Into, editable: bool) -> Self { + Self { + buffer_id, + label: label.into(), + editable, + style: PaneStyle::default(), + range: None, + } + } + + /// Set the visual style + pub fn with_style(mut self, style: PaneStyle) -> Self { + self.style = style; + self + } + + /// Set the byte range to display + pub fn with_range(mut self, range: Range) -> Self { + self.range = Some(range); + self + } +} + +/// Visual styling for a pane +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PaneStyle { + /// Background color for added lines (RGB) + pub add_bg: Option<(u8, u8, u8)>, + /// Background color for removed lines (RGB) + pub remove_bg: Option<(u8, u8, u8)>, + /// Background color for modified lines (RGB) + pub modify_bg: Option<(u8, u8, u8)>, + /// Gutter indicator style + pub gutter_style: GutterStyle, +} + +impl PaneStyle { + /// Create a style for the "old" side of a diff + pub fn old_diff() -> Self { + Self { + remove_bg: Some((80, 30, 30)), + gutter_style: GutterStyle::Both, + ..Default::default() + } + } + + /// Create a style for the "new" side of a diff + pub fn new_diff() -> Self { + Self { + add_bg: Some((30, 80, 30)), + modify_bg: Some((80, 80, 30)), + gutter_style: GutterStyle::Both, + ..Default::default() + } + } +} + +/// Gutter display style +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GutterStyle { + /// Show line numbers + #[default] + LineNumbers, + /// Show diff markers (+/-/~) + DiffMarkers, + /// Show both line numbers and markers + Both, + /// Hide gutter + None, +} + +// ============================================================================ +// Line Alignment +// ============================================================================ + +/// Alignment information for side-by-side views +#[derive(Debug, Clone, Default)] +pub struct LineAlignment { + /// Each entry maps a display row to source lines in each pane + /// None means padding (blank line) for that pane + pub rows: Vec, +} + +impl LineAlignment { + /// Create an empty alignment for the given number of panes + pub fn empty(_pane_count: usize) -> Self { + Self { rows: Vec::new() } + } + + /// Create alignment from simple line-by-line mapping (no diff) + /// Assumes both buffers have the same number of lines + pub fn simple(line_count: usize, pane_count: usize) -> Self { + let rows = (0..line_count) + .map(|line| AlignedRow { + pane_lines: (0..pane_count) + .map(|_| { + Some(SourceLineRef { + line, + byte_range: 0..0, // Will be filled in during render + }) + }) + .collect(), + row_type: RowType::Context, + }) + .collect(); + Self { rows } + } + + /// Create alignment from diff hunks + pub fn from_hunks(hunks: &[DiffHunk], old_line_count: usize, new_line_count: usize) -> Self { + let mut rows = Vec::new(); + let mut old_line = 0usize; + let mut new_line = 0usize; + + for hunk in hunks { + // Add context lines before this hunk + while old_line < hunk.old_start && new_line < hunk.new_start { + rows.push(AlignedRow::context(old_line, new_line)); + old_line += 1; + new_line += 1; + } + + // Add hunk header + rows.push(AlignedRow::hunk_header()); + + // Process hunk lines + let old_end = hunk.old_start + hunk.old_count; + let new_end = hunk.new_start + hunk.new_count; + + // Use a simple alignment: pair lines where possible, then pad + let old_hunk_lines = old_end - hunk.old_start; + let new_hunk_lines = new_end - hunk.new_start; + let max_lines = old_hunk_lines.max(new_hunk_lines); + + for i in 0..max_lines { + let old_idx = if i < old_hunk_lines { + Some(hunk.old_start + i) + } else { + None + }; + let new_idx = if i < new_hunk_lines { + Some(hunk.new_start + i) + } else { + None + }; + + let row_type = match (old_idx, new_idx) { + (Some(_), Some(_)) => RowType::Modification, + (Some(_), None) => RowType::Deletion, + (None, Some(_)) => RowType::Addition, + (None, None) => continue, + }; + + rows.push(AlignedRow { + pane_lines: vec![ + old_idx.map(|l| SourceLineRef { + line: l, + byte_range: 0..0, + }), + new_idx.map(|l| SourceLineRef { + line: l, + byte_range: 0..0, + }), + ], + row_type, + }); + } + + old_line = old_end; + new_line = new_end; + } + + // Add remaining context lines after last hunk + while old_line < old_line_count && new_line < new_line_count { + rows.push(AlignedRow::context(old_line, new_line)); + old_line += 1; + new_line += 1; + } + + // Handle trailing lines in either buffer + while old_line < old_line_count { + rows.push(AlignedRow { + pane_lines: vec![ + Some(SourceLineRef { + line: old_line, + byte_range: 0..0, + }), + None, + ], + row_type: RowType::Deletion, + }); + old_line += 1; + } + while new_line < new_line_count { + rows.push(AlignedRow { + pane_lines: vec![ + None, + Some(SourceLineRef { + line: new_line, + byte_range: 0..0, + }), + ], + row_type: RowType::Addition, + }); + new_line += 1; + } + + Self { rows } + } + + /// Get the aligned row at the given display index + pub fn get_row(&self, display_row: usize) -> Option<&AlignedRow> { + self.rows.get(display_row) + } + + /// Get the number of display rows + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Find the next hunk header row after the given row + pub fn next_hunk_row(&self, after_row: usize) -> Option { + self.rows + .iter() + .enumerate() + .skip(after_row + 1) + .find(|(_, row)| row.row_type == RowType::HunkHeader) + .map(|(i, _)| i) + } + + /// Find the previous hunk header row before the given row + pub fn prev_hunk_row(&self, before_row: usize) -> Option { + self.rows + .iter() + .enumerate() + .take(before_row) + .rev() + .find(|(_, row)| row.row_type == RowType::HunkHeader) + .map(|(i, _)| i) + } +} + +/// A single aligned row mapping display to source lines +#[derive(Debug, Clone)] +pub struct AlignedRow { + /// Source line for each pane (None = padding) + pub pane_lines: Vec>, + /// Type of this row for styling + pub row_type: RowType, +} + +impl AlignedRow { + /// Create a context row (both sides have content) + pub fn context(old_line: usize, new_line: usize) -> Self { + Self { + pane_lines: vec![ + Some(SourceLineRef { + line: old_line, + byte_range: 0..0, + }), + Some(SourceLineRef { + line: new_line, + byte_range: 0..0, + }), + ], + row_type: RowType::Context, + } + } + + /// Create a hunk header row + pub fn hunk_header() -> Self { + Self { + pane_lines: vec![None, None], + row_type: RowType::HunkHeader, + } + } + + /// Get the source line for a specific pane + pub fn get_pane_line(&self, pane_index: usize) -> Option<&SourceLineRef> { + self.pane_lines.get(pane_index).and_then(|opt| opt.as_ref()) + } + + /// Check if this row has content in the given pane + pub fn has_content(&self, pane_index: usize) -> bool { + self.pane_lines + .get(pane_index) + .map(|opt| opt.is_some()) + .unwrap_or(false) + } +} + +/// Reference to a line in a source buffer +#[derive(Debug, Clone)] +pub struct SourceLineRef { + /// Line number in source buffer (0-indexed) + pub line: usize, + /// Byte range in source buffer (computed during render) + pub byte_range: Range, +} + +/// Type of an aligned row for styling +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RowType { + /// Both sides have matching content + Context, + /// Line exists only in left/old (deletion) + Deletion, + /// Line exists only in right/new (addition) + Addition, + /// Line differs between sides + Modification, + /// Hunk separator/header + HunkHeader, +} + +/// A diff hunk describing a contiguous change +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffHunk { + /// Starting line in old buffer (0-indexed) + pub old_start: usize, + /// Number of lines in old buffer + pub old_count: usize, + /// Starting line in new buffer (0-indexed) + pub new_start: usize, + /// Number of lines in new buffer + pub new_count: usize, + /// Optional header text (function context) + pub header: Option, +} + +impl DiffHunk { + /// Create a new diff hunk + pub fn new(old_start: usize, old_count: usize, new_start: usize, new_count: usize) -> Self { + Self { + old_start, + old_count, + new_start, + new_count, + header: None, + } + } + + /// Set the header text + pub fn with_header(mut self, header: impl Into) -> Self { + self.header = Some(header.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_line_alignment_from_hunks() { + // Test with a single hunk: old has 2 lines deleted, new has 3 lines added + let hunks = vec![DiffHunk::new(2, 2, 2, 3)]; + let alignment = LineAlignment::from_hunks(&hunks, 5, 6); + + // Should have: + // - 2 context rows (lines 0-1) + // - 1 hunk header + // - 3 hunk rows (max of 2 old, 3 new) + // - 1 context row (old line 4, new line 5) + assert!(alignment.rows.len() >= 7); + + // First two rows should be context + assert_eq!(alignment.rows[0].row_type, RowType::Context); + assert_eq!(alignment.rows[1].row_type, RowType::Context); + + // Third row should be hunk header + assert_eq!(alignment.rows[2].row_type, RowType::HunkHeader); + } + + #[test] + fn test_composite_buffer_focus() { + let sources = vec![ + SourcePane::new(BufferId(1), "OLD", false), + SourcePane::new(BufferId(2), "NEW", true), + ]; + let mut composite = CompositeBuffer::new( + BufferId(0), + "Test".to_string(), + "diff-view".to_string(), + CompositeLayout::default(), + sources, + ); + + assert_eq!(composite.active_pane, 0); + + composite.focus_next(); + assert_eq!(composite.active_pane, 1); + + composite.focus_next(); + assert_eq!(composite.active_pane, 0); // Wraps around + + composite.focus_prev(); + assert_eq!(composite.active_pane, 1); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index e91382f44..84d25f0bc 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -3,6 +3,7 @@ //! This module contains pure data structures with minimal external dependencies. pub mod buffer; +pub mod composite_buffer; pub mod control_event; pub mod cursor; pub mod document_model; diff --git a/src/services/plugins/api.rs b/src/services/plugins/api.rs index 205c82030..fba532d91 100644 --- a/src/services/plugins/api.rs +++ b/src/services/plugins/api.rs @@ -39,6 +39,11 @@ pub enum PluginResponse { request_id: u64, text: Result, }, + /// Response to CreateCompositeBuffer with the created buffer ID + CompositeBufferCreated { + request_id: u64, + buffer_id: BufferId, + }, } /// Information about a cursor in the editor @@ -102,6 +107,76 @@ pub struct LayoutHints { pub column_guides: Option>, } +// ============================================================================ +// Composite Buffer Configuration (for multi-buffer single-tab views) +// ============================================================================ + +/// Layout configuration for composite buffers +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositeLayoutConfig { + /// Layout type: "side-by-side", "stacked", or "unified" + #[serde(rename = "type")] + pub layout_type: String, + /// Width ratios for side-by-side (e.g., [0.5, 0.5]) + #[serde(default)] + pub ratios: Option>, + /// Show separator between panes + #[serde(default = "default_true")] + pub show_separator: bool, + /// Spacing for stacked layout + #[serde(default)] + pub spacing: Option, +} + +fn default_true() -> bool { + true +} + +/// Source pane configuration for composite buffers +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositeSourceConfig { + /// Buffer ID of the source buffer + pub buffer_id: usize, + /// Label for this pane (e.g., "OLD", "NEW") + pub label: String, + /// Whether this pane is editable + #[serde(default)] + pub editable: bool, + /// Style configuration + #[serde(default)] + pub style: Option, +} + +/// Style configuration for a composite pane +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CompositePaneStyle { + /// Background color for added lines (RGB) + #[serde(default)] + pub add_bg: Option<(u8, u8, u8)>, + /// Background color for removed lines (RGB) + #[serde(default)] + pub remove_bg: Option<(u8, u8, u8)>, + /// Background color for modified lines (RGB) + #[serde(default)] + pub modify_bg: Option<(u8, u8, u8)>, + /// Gutter style: "line-numbers", "diff-markers", "both", or "none" + #[serde(default)] + pub gutter_style: Option, +} + +/// Diff hunk for composite buffer alignment +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositeHunk { + /// Starting line in old buffer (0-indexed) + pub old_start: usize, + /// Number of lines in old buffer + pub old_count: usize, + /// Starting line in new buffer (0-indexed) + pub new_start: usize, + /// Number of lines in new buffer + pub new_count: usize, +} + /// Wire-format view token kind (serialized for plugin transforms) #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ViewTokenWireKind { @@ -593,6 +668,32 @@ pub enum PluginCommand { /// Close a buffer and remove it from all splits CloseBuffer { buffer_id: BufferId }, + /// Create a composite buffer that displays multiple source buffers + /// Used for side-by-side diff, unified diff, and 3-way merge views + CreateCompositeBuffer { + /// Display name (shown in tab bar) + name: String, + /// Mode name for keybindings (e.g., "diff-view") + mode: String, + /// Layout configuration + layout: CompositeLayoutConfig, + /// Source pane configurations + sources: Vec, + /// Diff hunks for line alignment (optional) + hunks: Option>, + /// Request ID for async response + request_id: Option, + }, + + /// Update alignment for a composite buffer (e.g., after source edit) + UpdateCompositeAlignment { + buffer_id: BufferId, + hunks: Vec, + }, + + /// Close a composite buffer + CloseCompositeBuffer { buffer_id: BufferId }, + /// Focus a specific split FocusSplit { split_id: SplitId }, diff --git a/src/services/plugins/runtime.rs b/src/services/plugins/runtime.rs index dd257df2e..837c4205a 100644 --- a/src/services/plugins/runtime.rs +++ b/src/services/plugins/runtime.rs @@ -2765,6 +2765,234 @@ async fn op_fresh_create_virtual_buffer( } } +// ============================================================================= +// Composite Buffer Operations +// ============================================================================= + +/// Layout configuration for composite buffers +#[derive(serde::Deserialize)] +struct TsCompositeLayoutConfig { + /// Layout type: "side-by-side", "stacked", or "unified" + layout_type: String, + /// Relative widths for side-by-side layout (e.g., [0.5, 0.5]) + ratios: Option>, + /// Show separator between panes + show_separator: Option, + /// Spacing between stacked panes + spacing: Option, +} + +/// Pane style configuration +#[derive(serde::Deserialize)] +struct TsCompositePaneStyle { + /// Background color for added lines (RGB tuple) + add_bg: Option<(u8, u8, u8)>, + /// Background color for removed lines (RGB tuple) + remove_bg: Option<(u8, u8, u8)>, + /// Background color for modified lines (RGB tuple) + modify_bg: Option<(u8, u8, u8)>, + /// Gutter style: "line-numbers", "diff-markers", "both", "none" + gutter_style: Option, +} + +/// Source pane configuration for composite buffers +#[derive(serde::Deserialize)] +struct TsCompositeSourceConfig { + /// Buffer ID to display in this pane + buffer_id: u32, + /// Label for the pane (shown in header) + label: Option, + /// Whether the pane is editable + editable: bool, + /// Pane styling options + style: Option, +} + +/// Diff hunk configuration +#[derive(serde::Deserialize)] +struct TsCompositeHunk { + /// Start line in old file (0-indexed) + old_start: usize, + /// Number of lines in old file + old_count: usize, + /// Start line in new file (0-indexed) + new_start: usize, + /// Number of lines in new file + new_count: usize, +} + +/// Options for creating a composite buffer +#[derive(serde::Deserialize)] +struct CreateCompositeBufferOptions { + /// Display name for the composite buffer (shown in tab) + name: String, + /// Mode for keybindings (e.g., "diff-view") + mode: String, + /// Layout configuration + layout: TsCompositeLayoutConfig, + /// Source panes to display + sources: Vec, + /// Optional diff hunks for line alignment + hunks: Option>, +} + +/// Create a composite buffer that displays multiple source buffers +/// +/// Composite buffers allow displaying multiple underlying buffers in a single +/// tab/view area with custom layouts (side-by-side, stacked, unified). +/// This is useful for diff views, merge conflict resolution, etc. +/// @param options - Configuration for the composite buffer +/// @returns Promise resolving to the buffer ID of the created composite buffer +#[op2(async)] +async fn op_fresh_create_composite_buffer( + state: Rc>, + #[serde] options: CreateCompositeBufferOptions, +) -> Result { + let receiver = { + let state = state.borrow(); + let runtime_state = state + .try_borrow::>>() + .ok_or_else(|| JsErrorBox::generic("Failed to get runtime state"))?; + let runtime_state = runtime_state.borrow(); + + // Allocate request ID + let request_id = { + let mut id = runtime_state.next_request_id.borrow_mut(); + let current = *id; + *id += 1; + current + }; + + // Create oneshot channel for response + let (tx, rx) = tokio::sync::oneshot::channel(); + + // Store the sender + { + let mut pending = runtime_state.pending_responses.lock().unwrap(); + pending.insert(request_id, tx); + } + + // Convert TypeScript config to plugin API config + let layout_config = crate::services::plugins::api::CompositeLayoutConfig { + layout_type: options.layout.layout_type, + ratios: options.layout.ratios, + show_separator: options.layout.show_separator.unwrap_or(true), + spacing: options.layout.spacing, + }; + + let source_configs: Vec = options + .sources + .into_iter() + .map(|src| crate::services::plugins::api::CompositeSourceConfig { + buffer_id: src.buffer_id as usize, + label: src.label.unwrap_or_default(), + editable: src.editable, + style: src + .style + .map(|s| crate::services::plugins::api::CompositePaneStyle { + add_bg: s.add_bg, + remove_bg: s.remove_bg, + modify_bg: s.modify_bg, + gutter_style: s.gutter_style, + }), + }) + .collect(); + + let hunks: Option> = + options.hunks.map(|h| { + h.into_iter() + .map(|hunk| crate::services::plugins::api::CompositeHunk { + old_start: hunk.old_start, + old_count: hunk.old_count, + new_start: hunk.new_start, + new_count: hunk.new_count, + }) + .collect() + }); + + // Send command + runtime_state + .command_sender + .send(PluginCommand::CreateCompositeBuffer { + name: options.name, + mode: options.mode, + layout: layout_config, + sources: source_configs, + hunks, + request_id: Some(request_id), + }) + .map_err(|_| JsErrorBox::generic("Failed to send command"))?; + + rx + }; + + // Wait for response + let response = receiver + .await + .map_err(|_| JsErrorBox::generic("Response channel closed"))?; + + // Extract buffer ID from response + match response { + crate::services::plugins::api::PluginResponse::CompositeBufferCreated { + buffer_id, .. + } => Ok(buffer_id.0 as u32), + _ => Err(JsErrorBox::generic( + "Unexpected plugin response for composite buffer creation", + )), + } +} + +/// Update line alignment for a composite buffer +/// @param buffer_id - The composite buffer ID +/// @param hunks - New diff hunks for alignment +#[op2] +fn op_fresh_update_composite_alignment( + state: &mut OpState, + buffer_id: u32, + #[serde] hunks: Vec, +) -> bool { + if let Some(runtime_state) = state.try_borrow::>>() { + let runtime_state = runtime_state.borrow(); + + let hunk_configs: Vec = hunks + .into_iter() + .map(|h| crate::services::plugins::api::CompositeHunk { + old_start: h.old_start, + old_count: h.old_count, + new_start: h.new_start, + new_count: h.new_count, + }) + .collect(); + + let _ = runtime_state + .command_sender + .send(PluginCommand::UpdateCompositeAlignment { + buffer_id: crate::model::event::BufferId(buffer_id as usize), + hunks: hunk_configs, + }); + true + } else { + false + } +} + +/// Close a composite buffer +/// @param buffer_id - The composite buffer ID to close +#[op2(fast)] +fn op_fresh_close_composite_buffer(state: &mut OpState, buffer_id: u32) -> bool { + if let Some(runtime_state) = state.try_borrow::>>() { + let runtime_state = runtime_state.borrow(); + let _ = runtime_state + .command_sender + .send(PluginCommand::CloseCompositeBuffer { + buffer_id: crate::model::event::BufferId(buffer_id as usize), + }); + true + } else { + false + } +} + /// Send an arbitrary LSP request and receive the raw JSON response /// @param language - Language ID (e.g., "cpp") /// @param method - Full LSP method (e.g., "textDocument/switchSourceHeader") @@ -3478,6 +3706,10 @@ extension!( op_fresh_create_virtual_buffer_in_split, op_fresh_create_virtual_buffer_in_existing_split, op_fresh_create_virtual_buffer, + // Composite buffer operations + op_fresh_create_composite_buffer, + op_fresh_update_composite_alignment, + op_fresh_close_composite_buffer, op_fresh_send_lsp_request, op_fresh_define_mode, op_fresh_show_buffer, @@ -3867,6 +4099,18 @@ impl TypeScriptRuntime { createVirtualBuffer(options) { return core.ops.op_fresh_create_virtual_buffer(options); }, + + // Composite buffer operations + createCompositeBuffer(options) { + return core.ops.op_fresh_create_composite_buffer(options); + }, + updateCompositeAlignment(bufferId, hunks) { + return core.ops.op_fresh_update_composite_alignment(bufferId, hunks); + }, + closeCompositeBuffer(bufferId) { + return core.ops.op_fresh_close_composite_buffer(bufferId); + }, + defineMode(name, parent, bindings, readOnly = false) { const parentStr = parent != null ? parent : ""; return core.ops.op_fresh_define_mode(name, parentStr, bindings, readOnly); @@ -4055,6 +4299,10 @@ impl TypeScriptRuntime { crate::services::plugins::api::PluginResponse::BufferText { request_id, .. } => { *request_id } + crate::services::plugins::api::PluginResponse::CompositeBufferCreated { + request_id, + .. + } => *request_id, }; let sender = { diff --git a/src/services/plugins/thread.rs b/src/services/plugins/thread.rs index d3ebf42a5..7c19a149c 100644 --- a/src/services/plugins/thread.rs +++ b/src/services/plugins/thread.rs @@ -453,6 +453,10 @@ fn respond_to_pending( request_id, .. } => *request_id, crate::services::plugins::api::PluginResponse::BufferText { request_id, .. } => *request_id, + crate::services::plugins::api::PluginResponse::CompositeBufferCreated { + request_id, + .. + } => *request_id, }; let sender = { diff --git a/src/state.rs b/src/state.rs index ad425a59e..9a3ea074a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -85,6 +85,11 @@ pub struct EditorState { /// but navigation, selection, and copy are still allowed pub editing_disabled: bool, + /// Whether this buffer is a composite buffer (multi-pane view) + /// When true, the buffer content is rendered by the composite renderer + /// instead of the normal buffer rendering path + pub is_composite_buffer: bool, + /// Whether to show whitespace tab indicators (→) for this buffer /// Set based on language config; defaults to true pub show_whitespace_tabs: bool, @@ -144,6 +149,7 @@ impl EditorState { text_properties: TextPropertyManager::new(), show_cursors: true, editing_disabled: false, + is_composite_buffer: false, show_whitespace_tabs: true, use_tabs: false, tab_size: 4, // Default tab size @@ -161,14 +167,26 @@ impl EditorState { /// Set the syntax highlighting language based on a filename or extension /// This allows virtual buffers to get highlighting even without a real file path pub fn set_language_from_name(&mut self, name: &str, registry: &GrammarRegistry) { - let path = std::path::Path::new(name); + // Handle virtual buffer names like "*OLD:test.ts*" or "*OURS*.c" + // 1. Strip surrounding * characters + // 2. Extract filename after any prefix like "OLD:" or "NEW:" + let cleaned_name = name.trim_matches('*'); + let filename = if let Some(pos) = cleaned_name.rfind(':') { + // Extract part after the last colon (e.g., "OLD:test.ts" -> "test.ts") + &cleaned_name[pos + 1..] + } else { + cleaned_name + }; + + let path = std::path::Path::new(filename); self.highlighter = HighlightEngine::for_file(path, registry); if let Some(language) = Language::from_path(path) { self.semantic_highlighter.set_language(&language); } tracing::debug!( - "Set highlighter for virtual buffer based on name: {} (backend: {})", + "Set highlighter for virtual buffer based on name: {} -> {} (backend: {})", name, + filename, self.highlighter.backend_name() ); } @@ -226,6 +244,7 @@ impl EditorState { text_properties: TextPropertyManager::new(), show_cursors: true, editing_disabled: false, + is_composite_buffer: false, show_whitespace_tabs: true, use_tabs: false, tab_size: 4, // Default tab size @@ -297,6 +316,7 @@ impl EditorState { text_properties: TextPropertyManager::new(), show_cursors: true, editing_disabled: false, + is_composite_buffer: false, show_whitespace_tabs: true, use_tabs: false, tab_size: 4, // Default tab size diff --git a/src/view/composite_view.rs b/src/view/composite_view.rs new file mode 100644 index 000000000..064c7e02d --- /dev/null +++ b/src/view/composite_view.rs @@ -0,0 +1,418 @@ +//! View state for composite buffers +//! +//! Manages viewport, cursor, and focus state for composite buffer rendering. + +use crate::model::cursor::Cursors; +use crate::model::event::BufferId; +use ratatui::layout::Rect; + +/// View state for a composite buffer in a split +#[derive(Debug, Clone)] +pub struct CompositeViewState { + /// The composite buffer being displayed + pub composite_id: BufferId, + + /// Independent viewport per pane + pub pane_viewports: Vec, + + /// Which pane has focus (0-indexed) + pub focused_pane: usize, + + /// Single scroll position (display row) + /// All panes scroll together via alignment + pub scroll_row: usize, + + /// Current cursor row (for navigation highlighting) + pub cursor_row: usize, + + /// Current cursor column within the focused pane + pub cursor_column: usize, + + /// Cursor positions per pane (for editing) + pub pane_cursors: Vec, + + /// Width of each pane (computed during render) + pub pane_widths: Vec, + + /// Whether visual selection mode is active + pub visual_mode: bool, + + /// Selection anchor row (where selection started) + pub selection_anchor_row: usize, + + /// Selection anchor column (where selection started) + pub selection_anchor_column: usize, +} + +impl CompositeViewState { + /// Create a new composite view state for the given buffer + pub fn new(composite_id: BufferId, pane_count: usize) -> Self { + Self { + composite_id, + pane_viewports: (0..pane_count).map(|_| PaneViewport::default()).collect(), + focused_pane: 0, + scroll_row: 0, + cursor_row: 0, + cursor_column: 0, + pane_cursors: (0..pane_count).map(|_| Cursors::new()).collect(), + pane_widths: vec![0; pane_count], + visual_mode: false, + selection_anchor_row: 0, + selection_anchor_column: 0, + } + } + + /// Start visual selection at current cursor position + pub fn start_visual_selection(&mut self) { + self.visual_mode = true; + self.selection_anchor_row = self.cursor_row; + self.selection_anchor_column = self.cursor_column; + } + + /// Clear visual selection + pub fn clear_selection(&mut self) { + self.visual_mode = false; + } + + /// Get selection row range (start_row, end_row) inclusive + /// Returns None if not in visual mode + pub fn selection_row_range(&self) -> Option<(usize, usize)> { + if !self.visual_mode { + return None; + } + let start = self.selection_anchor_row.min(self.cursor_row); + let end = self.selection_anchor_row.max(self.cursor_row); + Some((start, end)) + } + + /// Check if a row is within the selection + pub fn is_row_selected(&self, row: usize) -> bool { + if !self.visual_mode { + return false; + } + let (start, end) = self.selection_row_range().unwrap(); + row >= start && row <= end + } + + /// Move cursor down, auto-scrolling if needed + pub fn move_cursor_down(&mut self, max_row: usize, viewport_height: usize) { + if self.cursor_row < max_row { + self.cursor_row += 1; + // Auto-scroll if cursor goes below viewport + if self.cursor_row >= self.scroll_row + viewport_height { + self.scroll_row = self.cursor_row.saturating_sub(viewport_height - 1); + } + } + } + + /// Move cursor up, auto-scrolling if needed + pub fn move_cursor_up(&mut self) { + if self.cursor_row > 0 { + self.cursor_row -= 1; + // Auto-scroll if cursor goes above viewport + if self.cursor_row < self.scroll_row { + self.scroll_row = self.cursor_row; + } + } + } + + /// Move cursor to top + pub fn move_cursor_to_top(&mut self) { + self.cursor_row = 0; + self.scroll_row = 0; + } + + /// Move cursor to bottom + pub fn move_cursor_to_bottom(&mut self, max_row: usize, viewport_height: usize) { + self.cursor_row = max_row; + self.scroll_row = max_row.saturating_sub(viewport_height - 1); + } + + /// Move cursor left by one column + pub fn move_cursor_left(&mut self) { + if self.cursor_column > 0 { + self.cursor_column -= 1; + // Auto-scroll horizontally if needed + if let Some(viewport) = self.pane_viewports.get_mut(self.focused_pane) { + if self.cursor_column < viewport.left_column { + viewport.left_column = self.cursor_column; + } + } + } + } + + /// Move cursor right by one column + pub fn move_cursor_right(&mut self, max_column: usize, pane_width: usize) { + if self.cursor_column < max_column { + self.cursor_column += 1; + // Auto-scroll horizontally if needed + if let Some(viewport) = self.pane_viewports.get_mut(self.focused_pane) { + let visible_width = pane_width.saturating_sub(4); // minus gutter + if self.cursor_column >= viewport.left_column + visible_width { + viewport.left_column = self.cursor_column.saturating_sub(visible_width - 1); + } + } + } + } + + /// Move cursor to start of line + pub fn move_cursor_to_line_start(&mut self) { + self.cursor_column = 0; + if let Some(viewport) = self.pane_viewports.get_mut(self.focused_pane) { + viewport.left_column = 0; + } + } + + /// Move cursor to end of line + pub fn move_cursor_to_line_end(&mut self, line_length: usize, pane_width: usize) { + self.cursor_column = line_length; + // Auto-scroll to show cursor + if let Some(viewport) = self.pane_viewports.get_mut(self.focused_pane) { + let visible_width = pane_width.saturating_sub(4); // minus gutter + if self.cursor_column >= viewport.left_column + visible_width { + viewport.left_column = self.cursor_column.saturating_sub(visible_width - 1); + } + } + } + + /// Scroll all panes together by delta lines + pub fn scroll(&mut self, delta: isize, max_row: usize) { + if delta >= 0 { + self.scroll_row = self.scroll_row.saturating_add(delta as usize).min(max_row); + } else { + self.scroll_row = self.scroll_row.saturating_sub(delta.unsigned_abs()); + } + } + + /// Set scroll to a specific row + pub fn set_scroll_row(&mut self, row: usize, max_row: usize) { + self.scroll_row = row.min(max_row); + } + + /// Scroll to top + pub fn scroll_to_top(&mut self) { + self.scroll_row = 0; + } + + /// Scroll to bottom + pub fn scroll_to_bottom(&mut self, total_rows: usize, viewport_height: usize) { + self.scroll_row = total_rows.saturating_sub(viewport_height); + } + + /// Page down + pub fn page_down(&mut self, viewport_height: usize, max_row: usize) { + self.scroll_row = self.scroll_row.saturating_add(viewport_height).min(max_row); + } + + /// Page up + pub fn page_up(&mut self, viewport_height: usize) { + self.scroll_row = self.scroll_row.saturating_sub(viewport_height); + } + + /// Switch focus to the next pane + pub fn focus_next_pane(&mut self) { + if !self.pane_viewports.is_empty() { + self.focused_pane = (self.focused_pane + 1) % self.pane_viewports.len(); + } + } + + /// Switch focus to the previous pane + pub fn focus_prev_pane(&mut self) { + let count = self.pane_viewports.len(); + if count > 0 { + self.focused_pane = (self.focused_pane + count - 1) % count; + } + } + + /// Set focus to a specific pane + pub fn set_focused_pane(&mut self, pane_index: usize) { + if pane_index < self.pane_viewports.len() { + self.focused_pane = pane_index; + } + } + + /// Get the viewport for a specific pane + pub fn get_pane_viewport(&self, pane_index: usize) -> Option<&PaneViewport> { + self.pane_viewports.get(pane_index) + } + + /// Get mutable viewport for a specific pane + pub fn get_pane_viewport_mut(&mut self, pane_index: usize) -> Option<&mut PaneViewport> { + self.pane_viewports.get_mut(pane_index) + } + + /// Get the cursor for a specific pane + pub fn get_pane_cursor(&self, pane_index: usize) -> Option<&Cursors> { + self.pane_cursors.get(pane_index) + } + + /// Get mutable cursor for a specific pane + pub fn get_pane_cursor_mut(&mut self, pane_index: usize) -> Option<&mut Cursors> { + self.pane_cursors.get_mut(pane_index) + } + + /// Get the focused pane's cursor + pub fn focused_cursor(&self) -> Option<&Cursors> { + self.pane_cursors.get(self.focused_pane) + } + + /// Get mutable reference to the focused pane's cursor + pub fn focused_cursor_mut(&mut self) -> Option<&mut Cursors> { + self.pane_cursors.get_mut(self.focused_pane) + } + + /// Update pane widths based on layout ratios and total width + pub fn update_pane_widths(&mut self, total_width: u16, ratios: &[f32], separator_width: u16) { + let separator_count = if self.pane_viewports.len() > 1 { + self.pane_viewports.len() - 1 + } else { + 0 + }; + let available_width = total_width.saturating_sub(separator_count as u16 * separator_width); + + self.pane_widths.clear(); + for ratio in ratios { + let width = (available_width as f32 * ratio).round() as u16; + self.pane_widths.push(width); + } + + // Adjust last pane to account for rounding + let total: u16 = self.pane_widths.iter().sum(); + if total < available_width { + if let Some(last) = self.pane_widths.last_mut() { + *last += available_width - total; + } + } else if total > available_width { + if let Some(last) = self.pane_widths.last_mut() { + *last = last.saturating_sub(total - available_width); + } + } + } + + /// Compute rects for each pane given the total area + pub fn compute_pane_rects(&self, area: Rect, separator_width: u16) -> Vec { + let mut rects = Vec::with_capacity(self.pane_widths.len()); + let mut x = area.x; + + for (i, &width) in self.pane_widths.iter().enumerate() { + rects.push(Rect { + x, + y: area.y, + width, + height: area.height, + }); + x += width; + if i < self.pane_widths.len() - 1 { + x += separator_width; + } + } + + rects + } +} + +/// Viewport state for a single pane within a composite +#[derive(Debug, Clone, Default)] +pub struct PaneViewport { + /// Computed rect for this pane (set during render) + pub rect: Rect, + /// Horizontal scroll offset for this pane + pub left_column: usize, +} + +impl PaneViewport { + /// Create a new pane viewport + pub fn new() -> Self { + Self::default() + } + + /// Set the rect for this pane + pub fn set_rect(&mut self, rect: Rect) { + self.rect = rect; + } + + /// Scroll horizontally + pub fn scroll_horizontal(&mut self, delta: isize, max_column: usize) { + if delta >= 0 { + self.left_column = self + .left_column + .saturating_add(delta as usize) + .min(max_column); + } else { + self.left_column = self.left_column.saturating_sub(delta.unsigned_abs()); + } + } + + /// Reset horizontal scroll + pub fn reset_horizontal_scroll(&mut self) { + self.left_column = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_composite_view_scroll() { + let mut view = CompositeViewState::new(BufferId(1), 2); + assert_eq!(view.scroll_row, 0); + + view.scroll(10, 100); + assert_eq!(view.scroll_row, 10); + + view.scroll(-5, 100); + assert_eq!(view.scroll_row, 5); + + view.scroll(-10, 100); + assert_eq!(view.scroll_row, 0); // Doesn't go negative + } + + #[test] + fn test_composite_view_focus() { + let mut view = CompositeViewState::new(BufferId(1), 3); + assert_eq!(view.focused_pane, 0); + + view.focus_next_pane(); + assert_eq!(view.focused_pane, 1); + + view.focus_next_pane(); + assert_eq!(view.focused_pane, 2); + + view.focus_next_pane(); + assert_eq!(view.focused_pane, 0); // Wraps around + + view.focus_prev_pane(); + assert_eq!(view.focused_pane, 2); + } + + #[test] + fn test_pane_width_calculation() { + let mut view = CompositeViewState::new(BufferId(1), 2); + view.update_pane_widths(100, &[0.5, 0.5], 1); + + assert_eq!(view.pane_widths.len(), 2); + // 100 - 1 (separator) = 99, 99 * 0.5 = 49.5 ≈ 50 + assert!(view.pane_widths[0] + view.pane_widths[1] == 99); + } + + #[test] + fn test_compute_pane_rects() { + let mut view = CompositeViewState::new(BufferId(1), 2); + view.update_pane_widths(101, &[0.5, 0.5], 1); + + let area = Rect { + x: 0, + y: 0, + width: 101, + height: 50, + }; + let rects = view.compute_pane_rects(area, 1); + + assert_eq!(rects.len(), 2); + assert_eq!(rects[0].x, 0); + assert_eq!(rects[1].x, rects[0].width + 1); // After separator + assert_eq!(rects[0].height, 50); + assert_eq!(rects[1].height, 50); + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs index 87a826ce9..a0277b7bb 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -4,6 +4,7 @@ pub mod calibration_wizard; pub mod color_support; +pub mod composite_view; pub mod controls; pub mod dimming; pub mod file_browser_input; diff --git a/src/view/split.rs b/src/view/split.rs index eb3981c74..97fd0288e 100644 --- a/src/view/split.rs +++ b/src/view/split.rs @@ -109,6 +109,12 @@ pub struct SplitViewState { /// Sync group ID for synchronized scrolling /// Splits with the same sync_group will scroll together pub sync_group: Option, + + /// When set, this split renders a composite view (e.g., side-by-side diff). + /// The split's buffer_id is the focused source buffer, but rendering uses + /// the composite layout. This makes the source buffer the "active buffer" + /// so normal keybindings work directly. + pub composite_view: Option, } impl SplitViewState { @@ -128,6 +134,7 @@ impl SplitViewState { layout_dirty: true, // Start dirty so first operation builds layout previous_buffer: None, sync_group: None, + composite_view: None, } } @@ -147,6 +154,7 @@ impl SplitViewState { layout_dirty: true, // Start dirty so first operation builds layout previous_buffer: None, sync_group: None, + composite_view: None, } } diff --git a/src/view/theme.rs b/src/view/theme.rs index bba6cdd64..7ac23ae95 100644 --- a/src/view/theme.rs +++ b/src/view/theme.rs @@ -88,6 +88,24 @@ struct EditorColors { current_line_bg: ColorDef, line_number_fg: ColorDef, line_number_bg: ColorDef, + #[serde(default = "default_diff_add_bg")] + diff_add_bg: ColorDef, + #[serde(default = "default_diff_remove_bg")] + diff_remove_bg: ColorDef, + #[serde(default = "default_diff_modify_bg")] + diff_modify_bg: ColorDef, +} + +fn default_diff_add_bg() -> ColorDef { + ColorDef::Rgb(35, 60, 35) // Dark green +} + +fn default_diff_remove_bg() -> ColorDef { + ColorDef::Rgb(70, 35, 35) // Dark red +} + +fn default_diff_modify_bg() -> ColorDef { + ColorDef::Rgb(70, 60, 30) // Dark yellow } fn default_inactive_cursor() -> ColorDef { @@ -353,6 +371,11 @@ pub struct Theme { pub line_number_fg: Color, pub line_number_bg: Color, + // Diff highlighting colors + pub diff_add_bg: Color, + pub diff_remove_bg: Color, + pub diff_modify_bg: Color, + // UI element colors pub tab_active_fg: Color, pub tab_active_bg: Color, @@ -474,6 +497,9 @@ impl From for Theme { current_line_bg: file.editor.current_line_bg.into(), line_number_fg: file.editor.line_number_fg.into(), line_number_bg: file.editor.line_number_bg.into(), + diff_add_bg: file.editor.diff_add_bg.into(), + diff_remove_bg: file.editor.diff_remove_bg.into(), + diff_modify_bg: file.editor.diff_modify_bg.into(), tab_active_fg: file.ui.tab_active_fg.into(), tab_active_bg: file.ui.tab_active_bg.into(), tab_inactive_fg: file.ui.tab_inactive_fg.into(), @@ -609,6 +635,11 @@ impl Theme { line_number_fg: Color::Rgb(100, 100, 100), line_number_bg: Color::Rgb(30, 30, 30), + // Diff highlighting colors + diff_add_bg: Color::Rgb(35, 60, 35), // Dark green + diff_remove_bg: Color::Rgb(70, 35, 35), // Dark red + diff_modify_bg: Color::Rgb(70, 60, 30), // Dark yellow/orange + // UI element colors tab_active_fg: Color::Yellow, tab_active_bg: Color::Blue, @@ -733,6 +764,11 @@ impl Theme { line_number_fg: Color::Rgb(140, 140, 140), line_number_bg: Color::Rgb(255, 255, 255), + // Diff highlighting colors + diff_add_bg: Color::Rgb(200, 255, 200), // Light green + diff_remove_bg: Color::Rgb(255, 200, 200), // Light red + diff_modify_bg: Color::Rgb(255, 240, 180), // Light yellow + // UI element colors tab_active_fg: Color::Rgb(40, 40, 40), tab_active_bg: Color::Rgb(255, 255, 255), @@ -857,6 +893,11 @@ impl Theme { line_number_fg: Color::Rgb(140, 140, 140), line_number_bg: Color::Black, + // Diff highlighting colors + diff_add_bg: Color::Rgb(0, 80, 0), // Dark green + diff_remove_bg: Color::Rgb(100, 0, 0), // Dark red + diff_modify_bg: Color::Rgb(100, 80, 0), // Dark yellow + // UI element colors tab_active_fg: Color::Black, tab_active_bg: Color::Yellow, @@ -1031,6 +1072,11 @@ impl Theme { line_number_fg: Color::Rgb(85, 255, 255), // Cyan line_number_bg: Color::Rgb(0, 0, 170), + // Diff highlighting colors + diff_add_bg: Color::Rgb(0, 100, 0), // DOS green + diff_remove_bg: Color::Rgb(170, 0, 0), // DOS red + diff_modify_bg: Color::Rgb(170, 85, 0), // DOS orange + // UI element colors tab_active_fg: Color::Rgb(0, 0, 0), tab_active_bg: Color::Rgb(170, 170, 170), diff --git a/src/view/ui/split_rendering.rs b/src/view/ui/split_rendering.rs index b0b2e622a..e393922af 100644 --- a/src/view/ui/split_rendering.rs +++ b/src/view/ui/split_rendering.rs @@ -640,6 +640,11 @@ impl SplitRenderer { buffers: &mut HashMap, buffer_metadata: &HashMap, event_logs: &mut HashMap, + composite_buffers: &HashMap, + composite_view_states: &mut HashMap< + (crate::model::event::SplitId, BufferId), + crate::view::composite_view::CompositeViewState, + >, theme: &crate::view::theme::Theme, ansi_background: Option<&AnsiBackground>, background_fade: f32, @@ -773,6 +778,45 @@ impl SplitRenderer { let event_log_opt = event_logs.get_mut(&buffer_id); if let Some(state) = state_opt { + // Check if this is a composite buffer - render differently + if state.is_composite_buffer { + if let Some(composite) = composite_buffers.get(&buffer_id) { + // Get or create composite view state + let pane_count = composite.pane_count(); + let view_state = composite_view_states + .entry((split_id, buffer_id)) + .or_insert_with(|| { + crate::view::composite_view::CompositeViewState::new( + buffer_id, pane_count, + ) + }); + // Render composite buffer with side-by-side panes + Self::render_composite_buffer( + frame, + layout.content_rect, + composite, + buffers, + theme, + is_active, + view_state, + ); + + // Render scrollbar for composite buffer + let total_rows = composite.row_count(); + let content_height = layout.content_rect.height.saturating_sub(1) as usize; // -1 for header + Self::render_composite_scrollbar( + frame, + layout.scrollbar_rect, + total_rows, + view_state.scroll_row, + content_height, + is_active, + ); + } + view_line_mappings.insert(split_id, Vec::new()); + continue; + } + // Get viewport from SplitViewState (authoritative source) // We need to get it mutably for sync operations // Use as_deref() to get Option<&HashMap> for read-only operations @@ -928,6 +972,525 @@ impl SplitRenderer { } } + /// Render a composite buffer (side-by-side view of multiple source buffers) + /// Uses ViewLines for proper syntax highlighting, ANSI handling, etc. + fn render_composite_buffer( + frame: &mut Frame, + area: Rect, + composite: &crate::model::composite_buffer::CompositeBuffer, + buffers: &mut HashMap, + theme: &crate::view::theme::Theme, + _is_active: bool, + view_state: &crate::view::composite_view::CompositeViewState, + ) { + use crate::model::composite_buffer::{CompositeLayout, RowType}; + + let scroll_row = view_state.scroll_row; + let cursor_row = view_state.cursor_row; + + // Clear the area first + frame.render_widget(Clear, area); + + // Calculate pane widths based on layout + let pane_count = composite.sources.len(); + if pane_count == 0 { + return; + } + + // Extract show_separator from layout + let show_separator = match &composite.layout { + CompositeLayout::SideBySide { show_separator, .. } => *show_separator, + _ => false, + }; + + // Calculate pane areas + let separator_width = if show_separator { 1 } else { 0 }; + let total_separators = (pane_count.saturating_sub(1)) as u16 * separator_width; + let available_width = area.width.saturating_sub(total_separators); + + let pane_widths: Vec = match &composite.layout { + CompositeLayout::SideBySide { ratios, .. } => { + let default_ratio = 1.0 / pane_count as f32; + ratios + .iter() + .chain(std::iter::repeat(&default_ratio)) + .take(pane_count) + .map(|r| (available_width as f32 * r).round() as u16) + .collect() + } + _ => { + // Equal widths for stacked/unified layouts + let pane_width = available_width / pane_count as u16; + vec![pane_width; pane_count] + } + }; + + // Render headers first + let header_height = 1u16; + let mut x_offset = area.x; + for (idx, (source, &width)) in composite.sources.iter().zip(&pane_widths).enumerate() { + let header_area = Rect::new(x_offset, area.y, width, header_height); + let is_focused = idx == view_state.focused_pane; + + let header_style = if is_focused { + Style::default() + .fg(theme.tab_active_fg) + .bg(theme.tab_active_bg) + } else { + Style::default() + .fg(theme.tab_inactive_fg) + .bg(theme.tab_inactive_bg) + }; + + let header_text = format!(" {} ", source.label); + let header = Paragraph::new(header_text).style(header_style); + frame.render_widget(header, header_area); + + x_offset += width + separator_width; + } + + // Content area (below headers) + let content_y = area.y + header_height; + let content_height = area.height.saturating_sub(header_height); + let visible_rows = content_height as usize; + + // Render aligned rows + let alignment = &composite.alignment; + let total_rows = alignment.rows.len(); + + // Build ViewData and get syntax highlighting for each pane + // Store: (ViewLines, line->ViewLine mapping, highlight spans) + struct PaneRenderData { + lines: Vec, + line_to_view_line: HashMap, + highlight_spans: Vec, + } + + let mut pane_render_data: Vec> = Vec::new(); + + for (pane_idx, source) in composite.sources.iter().enumerate() { + if let Some(source_state) = buffers.get_mut(&source.buffer_id) { + // Find the first and last source lines we need for this pane + let visible_lines: Vec = alignment + .rows + .iter() + .skip(scroll_row) + .take(visible_rows) + .filter_map(|row| row.get_pane_line(pane_idx)) + .map(|r| r.line) + .collect(); + + let first_line = visible_lines.iter().copied().min(); + let last_line = visible_lines.iter().copied().max(); + + if let (Some(first_line), Some(last_line)) = (first_line, last_line) { + // Get byte range for highlighting + let top_byte = source_state + .buffer + .line_start_offset(first_line) + .unwrap_or(0); + let end_byte = source_state + .buffer + .line_start_offset(last_line + 1) + .unwrap_or(source_state.buffer.len()); + + // Get syntax highlighting spans from the highlighter + let highlight_spans = source_state.highlighter.highlight_viewport( + &source_state.buffer, + top_byte, + end_byte, + theme, + 1024, // highlight_context_bytes + ); + + // Create a temporary viewport for building view data + let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80); + let mut viewport = + crate::view::viewport::Viewport::new(pane_width, content_height); + viewport.top_byte = top_byte; + viewport.line_wrap_enabled = false; + + let pane_width = pane_widths.get(pane_idx).copied().unwrap_or(80) as usize; + let gutter_width = 4; // Line number width + let content_width = pane_width.saturating_sub(gutter_width); + + // Build ViewData for this pane + let view_data = Self::build_view_data( + source_state, + &viewport, + None, // No view transform + 80, // estimated_line_length + visible_rows + 10, // visible_count (add buffer) + false, // line_wrap_enabled + content_width, + gutter_width, + ); + + // Build source_line -> ViewLine index mapping + let mut line_to_view_line: HashMap = HashMap::new(); + let mut current_line = first_line; + for (idx, view_line) in view_data.lines.iter().enumerate() { + if should_show_line_number(view_line) { + line_to_view_line.insert(current_line, idx); + current_line += 1; + } + } + + pane_render_data.push(Some(PaneRenderData { + lines: view_data.lines, + line_to_view_line, + highlight_spans, + })); + } else { + pane_render_data.push(None); + } + } else { + pane_render_data.push(None); + } + } + + // Now render aligned rows using ViewLines + for view_row in 0..visible_rows { + let display_row = scroll_row + view_row; + if display_row >= total_rows { + // Fill with tildes for empty rows + let mut x = area.x; + for &width in &pane_widths { + let tilde_area = Rect::new(x, content_y + view_row as u16, width, 1); + let tilde = + Paragraph::new("~").style(Style::default().fg(theme.line_number_fg)); + frame.render_widget(tilde, tilde_area); + x += width + separator_width; + } + continue; + } + + let aligned_row = &alignment.rows[display_row]; + let is_cursor_row = display_row == cursor_row; + let is_selected = view_state.is_row_selected(display_row); + + // Determine row background based on type and selection + let row_bg = if is_selected { + // Selection background takes precedence + Some(theme.selection_bg) + } else { + match aligned_row.row_type { + RowType::Addition => Some(theme.diff_add_bg), + RowType::Deletion => Some(theme.diff_remove_bg), + RowType::Modification => Some(theme.diff_modify_bg), + RowType::HunkHeader => Some(theme.current_line_bg), + RowType::Context => None, + } + }; + + // Render each pane for this row + let mut x_offset = area.x; + for (pane_idx, (source, &width)) in + composite.sources.iter().zip(&pane_widths).enumerate() + { + let pane_area = Rect::new(x_offset, content_y + view_row as u16, width, 1); + + // Get horizontal scroll offset for this pane + let left_column = view_state + .get_pane_viewport(pane_idx) + .map(|v| v.left_column) + .unwrap_or(0); + + // Get source line for this pane + let source_line_opt = aligned_row.get_pane_line(pane_idx); + + if let Some(source_line_ref) = source_line_opt { + // Try to get ViewLine and highlight spans from pre-built data + let pane_data = pane_render_data.get(pane_idx).and_then(|opt| opt.as_ref()); + let view_line_opt = pane_data.and_then(|data| { + data.line_to_view_line + .get(&source_line_ref.line) + .and_then(|&idx| data.lines.get(idx)) + }); + let highlight_spans = pane_data + .map(|data| data.highlight_spans.as_slice()) + .unwrap_or(&[]); + + let gutter_width = 4usize; + let max_content_width = width.saturating_sub(gutter_width as u16) as usize; + + // Determine background + let bg = if is_cursor_row { + theme.current_line_bg + } else { + row_bg.unwrap_or(theme.editor_bg) + }; + + // Line number + let line_num = format!("{:>3} ", source_line_ref.line + 1); + let line_num_style = Style::default().fg(theme.line_number_fg).bg(bg); + + let is_cursor_pane = pane_idx == view_state.focused_pane; + let cursor_column = view_state.cursor_column; + + // Build spans using ViewLine if available (for syntax highlighting) + let mut spans = vec![Span::styled(line_num, line_num_style)]; + + if let Some(view_line) = view_line_opt { + // Use ViewLine for syntax-highlighted content + Self::render_view_line_content( + &mut spans, + view_line, + highlight_spans, + left_column, + max_content_width, + bg, + theme, + is_cursor_row && is_cursor_pane, + cursor_column, + ); + } else { + // Fallback: get content directly from buffer + if let Some(source_state) = buffers.get(&source.buffer_id) { + let line_content = source_state + .buffer + .get_line(source_line_ref.line) + .map(|line| String::from_utf8_lossy(&line).to_string()) + .unwrap_or_default(); + + let content_style = Style::default().fg(theme.editor_fg).bg(bg); + let chars: Vec = line_content.chars().collect(); + let scrolled: String = chars + .iter() + .skip(left_column) + .take(max_content_width) + .collect(); + let padded = format!("{:>, + view_line: &ViewLine, + highlight_spans: &[crate::primitives::highlighter::HighlightSpan], + left_column: usize, + max_width: usize, + bg: Color, + theme: &crate::view::theme::Theme, + show_cursor: bool, + cursor_column: usize, + ) { + let text = &view_line.text; + let char_source_bytes = &view_line.char_source_bytes; + + // Apply horizontal scroll and collect visible characters with styles + let chars: Vec = text.chars().collect(); + let mut col = 0usize; + let mut rendered = 0usize; + let mut current_span_text = String::new(); + let mut current_style: Option