|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useState, useMemo } from 'react' |
| 4 | +import { Icon } from '@iconify/react' |
| 5 | +import type { EditProposal } from '@/lib/edit-parser' |
| 6 | + |
| 7 | +interface Props { |
| 8 | + proposal: EditProposal |
| 9 | + original?: string |
| 10 | + onApply: (proposal: EditProposal) => void |
| 11 | + onReject?: () => void |
| 12 | +} |
| 13 | + |
| 14 | +interface DiffLine { |
| 15 | + type: 'add' | 'del' | 'ctx' |
| 16 | + content: string |
| 17 | + oldNum?: number |
| 18 | + newNum?: number |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * Compute a simple unified diff between two strings. |
| 23 | + * Uses a basic LCS-based line diff — no external deps. |
| 24 | + */ |
| 25 | +function computeDiff(oldText: string, newText: string): DiffLine[] { |
| 26 | + const oldLines = oldText.split('\n') |
| 27 | + const newLines = newText.split('\n') |
| 28 | + const lines: DiffLine[] = [] |
| 29 | + |
| 30 | + // Simple diff: find common prefix, suffix, then show changes |
| 31 | + let prefixLen = 0 |
| 32 | + while (prefixLen < oldLines.length && prefixLen < newLines.length && oldLines[prefixLen] === newLines[prefixLen]) { |
| 33 | + prefixLen++ |
| 34 | + } |
| 35 | + |
| 36 | + let suffixLen = 0 |
| 37 | + while ( |
| 38 | + suffixLen < oldLines.length - prefixLen && |
| 39 | + suffixLen < newLines.length - prefixLen && |
| 40 | + oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen] |
| 41 | + ) { |
| 42 | + suffixLen++ |
| 43 | + } |
| 44 | + |
| 45 | + // Context lines before changes (show up to 3) |
| 46 | + const ctxStart = Math.max(0, prefixLen - 3) |
| 47 | + for (let i = ctxStart; i < prefixLen; i++) { |
| 48 | + lines.push({ type: 'ctx', content: oldLines[i], oldNum: i + 1, newNum: i + 1 }) |
| 49 | + } |
| 50 | + |
| 51 | + // Deleted lines |
| 52 | + const oldEnd = oldLines.length - suffixLen |
| 53 | + for (let i = prefixLen; i < oldEnd; i++) { |
| 54 | + lines.push({ type: 'del', content: oldLines[i], oldNum: i + 1 }) |
| 55 | + } |
| 56 | + |
| 57 | + // Added lines |
| 58 | + const newEnd = newLines.length - suffixLen |
| 59 | + for (let i = prefixLen; i < newEnd; i++) { |
| 60 | + lines.push({ type: 'add', content: newLines[i], newNum: i + 1 }) |
| 61 | + } |
| 62 | + |
| 63 | + // Context lines after changes (show up to 3) |
| 64 | + const ctxEnd = Math.min(oldLines.length, oldEnd + 3) |
| 65 | + for (let i = oldEnd; i < ctxEnd; i++) { |
| 66 | + const newIdx = i - oldEnd + newEnd |
| 67 | + lines.push({ type: 'ctx', content: oldLines[i], oldNum: i + 1, newNum: newIdx + 1 }) |
| 68 | + } |
| 69 | + |
| 70 | + return lines |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Inline unified diff viewer — renders directly in chat message. |
| 75 | + * Codex-inspired: line numbers, gutter signs, approve/reject buttons. |
| 76 | + */ |
| 77 | +export function InlineDiff({ proposal, original, onApply, onReject }: Props) { |
| 78 | + const [collapsed, setCollapsed] = useState(false) |
| 79 | + const fileName = proposal.filePath.split('/').pop() || proposal.filePath |
| 80 | + const isNewFile = !original |
| 81 | + |
| 82 | + const diffLines = useMemo(() => { |
| 83 | + if (isNewFile) { |
| 84 | + return proposal.content.split('\n').map((line, i): DiffLine => ({ |
| 85 | + type: 'add', content: line, newNum: i + 1, |
| 86 | + })) |
| 87 | + } |
| 88 | + return computeDiff(original, proposal.content) |
| 89 | + }, [original, proposal.content, isNewFile]) |
| 90 | + |
| 91 | + const additions = diffLines.filter(l => l.type === 'add').length |
| 92 | + const deletions = diffLines.filter(l => l.type === 'del').length |
| 93 | + |
| 94 | + return ( |
| 95 | + <div className="rounded-lg border border-[var(--border)] overflow-hidden my-1.5"> |
| 96 | + {/* File header */} |
| 97 | + <div className="flex items-center gap-2 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-elevated)_90%,transparent)] border-b border-[var(--border)]"> |
| 98 | + <button onClick={() => setCollapsed(!collapsed)} className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer"> |
| 99 | + <Icon icon={collapsed ? 'lucide:chevron-right' : 'lucide:chevron-down'} width={12} className="text-[var(--text-disabled)] shrink-0" /> |
| 100 | + <Icon icon="lucide:file-diff" width={12} className="text-[var(--text-tertiary)] shrink-0" /> |
| 101 | + <span className="text-[11px] font-mono text-[var(--text-primary)] truncate">{proposal.filePath}</span> |
| 102 | + {isNewFile && ( |
| 103 | + <span className="text-[9px] px-1.5 py-0.5 rounded bg-[color-mix(in_srgb,#34d399_15%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))] font-medium">NEW</span> |
| 104 | + )} |
| 105 | + </button> |
| 106 | + <div className="flex items-center gap-2 shrink-0"> |
| 107 | + {additions > 0 && <span className="text-[10px] font-mono text-[color-mix(in_srgb,#34d399_80%,var(--brand))]">+{additions}</span>} |
| 108 | + {deletions > 0 && <span className="text-[10px] font-mono text-red-400">-{deletions}</span>} |
| 109 | + <button |
| 110 | + onClick={() => onApply(proposal)} |
| 111 | + className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors cursor-pointer |
| 112 | + bg-[color-mix(in_srgb,#34d399_12%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))] |
| 113 | + hover:bg-[color-mix(in_srgb,#34d399_20%,transparent)] |
| 114 | + border border-[color-mix(in_srgb,#34d399_25%,transparent)]" |
| 115 | + > |
| 116 | + <Icon icon="lucide:check" width={10} /> |
| 117 | + Apply |
| 118 | + </button> |
| 119 | + {onReject && ( |
| 120 | + <button |
| 121 | + onClick={onReject} |
| 122 | + className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors cursor-pointer |
| 123 | + text-[var(--text-disabled)] hover:text-red-400 |
| 124 | + hover:bg-[color-mix(in_srgb,red_8%,transparent)]" |
| 125 | + > |
| 126 | + <Icon icon="lucide:x" width={10} /> |
| 127 | + </button> |
| 128 | + )} |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + |
| 132 | + {/* Diff content */} |
| 133 | + {!collapsed && ( |
| 134 | + <div className="overflow-x-auto max-h-64 overflow-y-auto text-[11px] font-mono leading-[18px]"> |
| 135 | + {diffLines.length === 0 ? ( |
| 136 | + <div className="px-3 py-2 text-[var(--text-disabled)] text-[10px]">No changes</div> |
| 137 | + ) : ( |
| 138 | + <table className="w-full border-collapse"> |
| 139 | + <tbody> |
| 140 | + {diffLines.map((line, i) => ( |
| 141 | + <tr |
| 142 | + key={i} |
| 143 | + className={ |
| 144 | + line.type === 'add' |
| 145 | + ? 'bg-[color-mix(in_srgb,#34d399_6%,transparent)]' |
| 146 | + : line.type === 'del' |
| 147 | + ? 'bg-[color-mix(in_srgb,red_6%,transparent)]' |
| 148 | + : '' |
| 149 | + } |
| 150 | + > |
| 151 | + {/* Old line number */} |
| 152 | + <td className="w-8 text-right pr-1 select-none text-[9px] text-[var(--text-disabled)] align-top"> |
| 153 | + {line.type !== 'add' ? line.oldNum : ''} |
| 154 | + </td> |
| 155 | + {/* New line number */} |
| 156 | + <td className="w-8 text-right pr-1 select-none text-[9px] text-[var(--text-disabled)] align-top"> |
| 157 | + {line.type !== 'del' ? line.newNum : ''} |
| 158 | + </td> |
| 159 | + {/* Gutter sign */} |
| 160 | + <td className={`w-4 text-center select-none align-top ${ |
| 161 | + line.type === 'add' ? 'text-[color-mix(in_srgb,#34d399_80%,var(--brand))]' : |
| 162 | + line.type === 'del' ? 'text-red-400' : 'text-[var(--text-disabled)]' |
| 163 | + }`}> |
| 164 | + {line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' '} |
| 165 | + </td> |
| 166 | + {/* Content */} |
| 167 | + <td className={`pl-1 pr-3 whitespace-pre ${ |
| 168 | + line.type === 'add' ? 'text-[color-mix(in_srgb,#34d399_90%,var(--text-primary))]' : |
| 169 | + line.type === 'del' ? 'text-red-300' : 'text-[var(--text-secondary)]' |
| 170 | + }`}> |
| 171 | + {line.content || '\u00A0'} |
| 172 | + </td> |
| 173 | + </tr> |
| 174 | + ))} |
| 175 | + </tbody> |
| 176 | + </table> |
| 177 | + )} |
| 178 | + </div> |
| 179 | + )} |
| 180 | + </div> |
| 181 | + ) |
| 182 | +} |
| 183 | + |
| 184 | +/** |
| 185 | + * Batch diff view for multiple edit proposals. |
| 186 | + */ |
| 187 | +export function InlineDiffGroup({ |
| 188 | + proposals, |
| 189 | + getOriginal, |
| 190 | + onApply, |
| 191 | + onApplyAll, |
| 192 | +}: { |
| 193 | + proposals: EditProposal[] |
| 194 | + getOriginal: (filePath: string) => string | undefined |
| 195 | + onApply: (proposal: EditProposal) => void |
| 196 | + onApplyAll: () => void |
| 197 | +}) { |
| 198 | + if (proposals.length === 0) return null |
| 199 | + |
| 200 | + return ( |
| 201 | + <div className="space-y-1"> |
| 202 | + {proposals.map((proposal, i) => ( |
| 203 | + <InlineDiff |
| 204 | + key={`${proposal.filePath}-${i}`} |
| 205 | + proposal={proposal} |
| 206 | + original={getOriginal(proposal.filePath)} |
| 207 | + onApply={onApply} |
| 208 | + /> |
| 209 | + ))} |
| 210 | + {proposals.length > 1 && ( |
| 211 | + <button |
| 212 | + onClick={onApplyAll} |
| 213 | + className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium transition-colors cursor-pointer w-full justify-center |
| 214 | + bg-[color-mix(in_srgb,#34d399_10%,transparent)] text-[color-mix(in_srgb,#34d399_80%,var(--brand))] |
| 215 | + hover:bg-[color-mix(in_srgb,#34d399_18%,transparent)] |
| 216 | + border border-[color-mix(in_srgb,#34d399_20%,transparent)]" |
| 217 | + > |
| 218 | + <Icon icon="lucide:check-check" width={13} /> |
| 219 | + Apply all {proposals.length} files |
| 220 | + </button> |
| 221 | + )} |
| 222 | + </div> |
| 223 | + ) |
| 224 | +} |
0 commit comments