|
| 1 | +/** |
| 2 | + * CodeContent component - renders code with optional syntax highlighting. |
| 3 | + * Simplified version of DiffContent without diff-specific features. |
| 4 | + */ |
| 5 | +import { Box, Text } from "ink"; |
| 6 | +import * as React from "react"; |
| 7 | + |
| 8 | +import { buildAnsiStringWithLineBreaks, buildStyledBlock, type CharStyle } from "./ansiString"; |
| 9 | +import { useCodeViewContext } from "./CodeViewContext"; |
| 10 | +import { diffPlainContent } from "./color"; |
| 11 | +import { getStyleObjectFromString, getStyleFromClassName } from "./DiffContent"; |
| 12 | + |
| 13 | +import type { File } from "@git-diff-view/core"; |
| 14 | + |
| 15 | +// Helper to get tab width value |
| 16 | +const getTabWidthValue = (tabWidth: "small" | "medium" | "large"): number => { |
| 17 | + return tabWidth === "small" ? 1 : tabWidth === "medium" ? 2 : 4; |
| 18 | +}; |
| 19 | + |
| 20 | +// Process a string into styled characters for ANSI output |
| 21 | +const processCharsForAnsi = ( |
| 22 | + str: string, |
| 23 | + enableTabSpace: boolean, |
| 24 | + tabWidth: "small" | "medium" | "large", |
| 25 | + baseStyle: CharStyle |
| 26 | +): Array<{ char: string; style?: CharStyle }> => { |
| 27 | + const result: Array<{ char: string; style?: CharStyle }> = []; |
| 28 | + const tabWidthValue = getTabWidthValue(tabWidth); |
| 29 | + |
| 30 | + for (const char of str) { |
| 31 | + if (enableTabSpace && char === " ") { |
| 32 | + // Show space as dimmed dot |
| 33 | + result.push({ char: "\u00b7", style: { ...baseStyle, dim: true } }); |
| 34 | + } else if (char === "\t") { |
| 35 | + if (enableTabSpace) { |
| 36 | + // Show tab as arrow followed by spaces |
| 37 | + result.push({ char: "\u2192", style: { ...baseStyle, dim: true } }); |
| 38 | + for (let i = 1; i < tabWidthValue; i++) { |
| 39 | + result.push({ char: " ", style: baseStyle }); |
| 40 | + } |
| 41 | + } else { |
| 42 | + // Just show spaces for tab |
| 43 | + for (let i = 0; i < tabWidthValue; i++) { |
| 44 | + result.push({ char: " ", style: baseStyle }); |
| 45 | + } |
| 46 | + } |
| 47 | + } else { |
| 48 | + result.push({ char, style: baseStyle }); |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + return result; |
| 53 | +}; |
| 54 | + |
| 55 | +/** |
| 56 | + * CodeString component using ANSI escape codes for proper character-level wrapping. |
| 57 | + */ |
| 58 | +const CodeString = React.memo(({ bg, width, rawLine }: { bg: string; width: number; rawLine: string }) => { |
| 59 | + const { useCodeContext } = useCodeViewContext(); |
| 60 | + |
| 61 | + const { enableTabSpace, tabWidth } = useCodeContext((s) => ({ enableTabSpace: s.tabSpace, tabWidth: s.tabWidth })); |
| 62 | + |
| 63 | + // Memoize the ANSI content to avoid rebuilding on every render |
| 64 | + const ansiContent = React.useMemo(() => { |
| 65 | + const chars: Array<{ char: string; style?: CharStyle }> = []; |
| 66 | + const baseStyle: CharStyle = { backgroundColor: bg }; |
| 67 | + |
| 68 | + // Process the whole line |
| 69 | + chars.push(...processCharsForAnsi(rawLine, enableTabSpace, tabWidth, baseStyle)); |
| 70 | + |
| 71 | + return buildAnsiStringWithLineBreaks(chars, width); |
| 72 | + }, [bg, width, rawLine, enableTabSpace, tabWidth]); |
| 73 | + |
| 74 | + return ( |
| 75 | + <Box width={width} backgroundColor={bg}> |
| 76 | + <Text wrap="truncate">{ansiContent}</Text> |
| 77 | + </Box> |
| 78 | + ); |
| 79 | +}); |
| 80 | + |
| 81 | +CodeString.displayName = "CodeString"; |
| 82 | + |
| 83 | +/** |
| 84 | + * Helper function to process syntax-highlighted characters for ANSI output. |
| 85 | + */ |
| 86 | +const processSyntaxCharsForAnsi = ( |
| 87 | + str: string, |
| 88 | + enableTabSpace: boolean, |
| 89 | + tabWidth: "small" | "medium" | "large", |
| 90 | + baseStyle: CharStyle, |
| 91 | + syntaxColor?: string |
| 92 | +): Array<{ char: string; style?: CharStyle }> => { |
| 93 | + const result: Array<{ char: string; style?: CharStyle }> = []; |
| 94 | + const tabWidthValue = getTabWidthValue(tabWidth); |
| 95 | + |
| 96 | + for (const char of str) { |
| 97 | + const style: CharStyle = { |
| 98 | + ...baseStyle, |
| 99 | + color: syntaxColor || baseStyle.color, |
| 100 | + }; |
| 101 | + |
| 102 | + if (enableTabSpace && char === " ") { |
| 103 | + result.push({ char: "\u00b7", style: { ...style, dim: true } }); |
| 104 | + } else if (char === "\t") { |
| 105 | + if (enableTabSpace) { |
| 106 | + result.push({ char: "\u2192", style: { ...style, dim: true } }); |
| 107 | + for (let i = 1; i < tabWidthValue; i++) { |
| 108 | + result.push({ char: " ", style }); |
| 109 | + } |
| 110 | + } else { |
| 111 | + for (let i = 0; i < tabWidthValue; i++) { |
| 112 | + result.push({ char: " ", style }); |
| 113 | + } |
| 114 | + } |
| 115 | + } else { |
| 116 | + result.push({ char, style }); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + return result; |
| 121 | +}; |
| 122 | + |
| 123 | +/** |
| 124 | + * CodeSyntax component using ANSI escape codes for proper character-level wrapping |
| 125 | + * with syntax highlighting support. |
| 126 | + */ |
| 127 | +const CodeSyntax = React.memo( |
| 128 | + ({ |
| 129 | + bg, |
| 130 | + width, |
| 131 | + theme, |
| 132 | + rawLine, |
| 133 | + syntaxLine, |
| 134 | + }: { |
| 135 | + bg: string; |
| 136 | + width: number; |
| 137 | + theme: "light" | "dark"; |
| 138 | + rawLine: string; |
| 139 | + syntaxLine?: File["syntaxFile"][number]; |
| 140 | + }) => { |
| 141 | + const { useCodeContext } = useCodeViewContext(); |
| 142 | + |
| 143 | + const { enableTabSpace, tabWidth } = useCodeContext((s) => ({ enableTabSpace: s.tabSpace, tabWidth: s.tabWidth })); |
| 144 | + |
| 145 | + // Memoize the ANSI content with syntax highlighting |
| 146 | + const ansiContent = React.useMemo(() => { |
| 147 | + if (!syntaxLine) { |
| 148 | + return null; // Will render CodeString instead |
| 149 | + } |
| 150 | + |
| 151 | + const chars: Array<{ char: string; style?: CharStyle }> = []; |
| 152 | + const baseStyle: CharStyle = { backgroundColor: bg }; |
| 153 | + |
| 154 | + for (const { node, wrapper } of syntaxLine.nodeList || []) { |
| 155 | + // Get syntax color from lowlight or shiki |
| 156 | + const lowlightStyles = getStyleFromClassName(wrapper?.properties?.className?.join(" ") || ""); |
| 157 | + const lowlightStyle = theme === "dark" ? lowlightStyles.dark : lowlightStyles.light; |
| 158 | + const shikiStyles = getStyleObjectFromString(wrapper?.properties?.style || ""); |
| 159 | + const shikiStyle = theme === "dark" ? shikiStyles.dark : shikiStyles.light; |
| 160 | + |
| 161 | + // Determine the syntax color (shiki style takes precedence) |
| 162 | + const syntaxColor = (shikiStyle as { color?: string })?.color || (lowlightStyle as { color?: string })?.color; |
| 163 | + |
| 164 | + chars.push( |
| 165 | + ...processSyntaxCharsForAnsi(node.value, enableTabSpace, tabWidth, { ...baseStyle, color: syntaxColor }) |
| 166 | + ); |
| 167 | + } |
| 168 | + |
| 169 | + return buildAnsiStringWithLineBreaks(chars, width); |
| 170 | + }, [bg, width, theme, rawLine, syntaxLine, enableTabSpace, tabWidth]); |
| 171 | + |
| 172 | + // Fallback to CodeString if no syntax line |
| 173 | + if (!syntaxLine) { |
| 174 | + return <CodeString bg={bg} width={width} rawLine={rawLine} />; |
| 175 | + } |
| 176 | + |
| 177 | + return ( |
| 178 | + <Box width={width} backgroundColor={bg}> |
| 179 | + <Text wrap="truncate">{ansiContent}</Text> |
| 180 | + </Box> |
| 181 | + ); |
| 182 | + } |
| 183 | +); |
| 184 | + |
| 185 | +CodeSyntax.displayName = "CodeSyntax"; |
| 186 | + |
| 187 | +/** |
| 188 | + * CodePadding component - Renders a 1-char padding column |
| 189 | + * using chalk for proper multi-row support. |
| 190 | + */ |
| 191 | +const CodePadding = React.memo(({ height, backgroundColor }: { height: number; backgroundColor: string }) => { |
| 192 | + const content = React.useMemo(() => { |
| 193 | + const lines: string[] = []; |
| 194 | + const style: CharStyle = { backgroundColor }; |
| 195 | + |
| 196 | + for (let row = 0; row < height; row++) { |
| 197 | + lines.push(buildStyledBlock(" ", 1, 1, style, "left")); |
| 198 | + } |
| 199 | + |
| 200 | + return lines.join("\n"); |
| 201 | + }, [height, backgroundColor]); |
| 202 | + |
| 203 | + return ( |
| 204 | + <Box width={1} flexShrink={0}> |
| 205 | + <Text wrap="truncate">{content}</Text> |
| 206 | + </Box> |
| 207 | + ); |
| 208 | +}); |
| 209 | + |
| 210 | +CodePadding.displayName = "CodePadding"; |
| 211 | + |
| 212 | +export const CodeContent = React.memo( |
| 213 | + ({ |
| 214 | + theme, |
| 215 | + width, |
| 216 | + height, |
| 217 | + rawLine, |
| 218 | + syntaxLine, |
| 219 | + enableHighlight, |
| 220 | + }: { |
| 221 | + width: number; |
| 222 | + height: number; |
| 223 | + theme: "light" | "dark"; |
| 224 | + rawLine: string; |
| 225 | + plainLine?: File["plainFile"][number]; |
| 226 | + syntaxLine?: File["syntaxFile"][number]; |
| 227 | + enableHighlight: boolean; |
| 228 | + }) => { |
| 229 | + const isMaxLineLengthToIgnoreSyntax = syntaxLine?.nodeList?.length > 150; |
| 230 | + |
| 231 | + // Background color for normal code |
| 232 | + const bg = React.useMemo(() => { |
| 233 | + return theme === "light" ? diffPlainContent.light : diffPlainContent.dark; |
| 234 | + }, [theme]); |
| 235 | + |
| 236 | + // Content width is total width minus 2 char padding (1 on each side) |
| 237 | + const contentWidth = width - 2; |
| 238 | + |
| 239 | + return ( |
| 240 | + <Box height={height} width={width}> |
| 241 | + <CodePadding height={height} backgroundColor={bg} /> |
| 242 | + {enableHighlight && syntaxLine && !isMaxLineLengthToIgnoreSyntax ? ( |
| 243 | + <CodeSyntax bg={bg} theme={theme} width={contentWidth} rawLine={rawLine} syntaxLine={syntaxLine} /> |
| 244 | + ) : ( |
| 245 | + <CodeString bg={bg} width={contentWidth} rawLine={rawLine} /> |
| 246 | + )} |
| 247 | + <CodePadding height={height} backgroundColor={bg} /> |
| 248 | + </Box> |
| 249 | + ); |
| 250 | + } |
| 251 | +); |
| 252 | + |
| 253 | +CodeContent.displayName = "CodeContent"; |
0 commit comments